Signals (Server Cues)
Server-originated one-shot opaque events to a session, a room, or a zone — a bell everyone hears, a scripted cue, "somewhere a door opened". An EVENT, never state: never stored, never replayed, so a reconnecting player is told nothing about what they missed (which is what separates it from ZoneState/EntityState). No client verb exists, so a signal is unforgeable by construction. Spatial-optional: the zone route needs World, the rest does not.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
ISignalService
Server-originated one-shot opaque events, addressed to a scope the SERVER chooses: one session, a Core room, or a zone. The framework never interprets a signal id or its payload. Why this exists. Everything else that reaches a client is nearly this and misses in a different direction. Emotes are sourced by an ENTITY and fan out to that entity's observers, so the server can neither originate one nor choose who hears it. ZoneState and EntityState are STATE — last-write-wins and replayed on entry — so faking an event by setting a blob and clearing it races the change and re-fires on the next reconnect. Notifications are localized UI codes with string arguments, which is a different thing wearing the same shape. The gap left over is exactly this: a transient, targeted, opaque event. Never replayed, never stored. A signal that was missed is gone. That is the contract, not a limitation: a game that needs a returning player to learn something is describing state, and should use a state piece. No client verb. There is no client → server message, so a signal is unforgeable by construction rather than by validation.
-
int ToRoom(string room, ushort signalId, byte[] data = null, bool reliable = true)Sends a one-shot signal to everyone in a Core room.
-
bool ToSession(ISession session, ushort signalId, byte[] data = null, bool reliable = true)Sends a one-shot signal to a single session.
-
int ToZone(string zone, ushort signalId, byte[] data = null, bool reliable = true)Sends a one-shot signal to every entity currently in a zone. Requires the World piece; with none composed this is a no-op returning 0, exactly as Voice's proximity route is unavailable without a world (a zone has no meaning without one).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
SignalOptions
Signals tuning (defaults here, never inline).
-
int MaxDataBytes { get; set; }Largest opaque payload a signal may carry, in bytes. A signal is a CUE, not a data channel — it says "this happened", and anything a client needs to look up afterwards it already has (content, entity state, zone state). Oversized signals are dropped rather than truncated, because a half-delivered cue is worse than an absent one. Default 64.
Wire messages 1
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
Signal Server → recipients: one thing happened, once. The framework never interprets SignalId or Data — a game maps the id to its own cue (a sound, a screen shake, a line of text, a VFX), so a text-only client renders the same signal as a sentence. An event, never state. Nothing about a signal is stored, so nothing is replayed: a player who reconnects sees the world as it is and is told nothing about what happened while they were gone. That is the whole distinction from ZoneState / EntityState, and it is why "set a blob then clear it" is not a substitute — a cleared blob still replays on the next entry, and clearing races the change. There is no client → server counterpart on purpose. A signal is unforgeable because a client has no way to emit one.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
ISignalClient
The Signals piece, client side: one-shot opaque events the SERVER originated. The id and the payload are the GAME's vocabulary — this client never interprets them; the game maps an id to its own cue, so the same signal is a VFX in one project, a sound in another and a line of text in a third. There is nothing to query and nothing to cache: a signal is an event, so it is raised once and forgotten. If a game finds itself wanting "what was the last signal", it wants state — ZoneState or EntityState — not this. Send-side is deliberately absent: clients cannot emit signals, which is what makes a signal unforgeable.
-
event Action<Signal> ReceivedA signal arrived. Raised on the main thread, once per signal, never replayed.