Nebula

Wire protocol specification

Implement or inspect Nebula protocol version 30, including byte encodings, message layouts, delivery classes, and compatibility rules.

Use this specification to inspect Nebula traffic or implement a peer that interoperates with protocol version 30. It defines the application payload carried by Nebula's LiteNetLib connections. LiteNetLib packet headers, acknowledgements, fragmentation, and connection negotiation are outside this specification.

The words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY describe interoperability requirements. The source of truth for this version is Packages/com.1by3.nebula/Runtime/Protocol/Messages.cs.

Compatibility

The current protocol version is 30. Every Hello carries the version its sender speaks.

A gateway accepts clients of protocol 29 and 30: HelloMsg.MinProtocolVersion is 29 and HelloMsg.ProtocolVersion is 30, and a client of 28 or older is refused with ProtocolUnsupported and the gateway's range. Gateway-to-worker and worker-to-worker connections MUST match protocol 30 exactly. Before 1.0 a protocol change may close the client window to the new version; protocols 23 and 28 did, and protocols 24 to 27, 29 and 30 did not.

Protocol 30 added motion fidelity to the batched transform state: an ext byte after each entry's fields, an optional angular velocity, and a 6-byte precise compressed quaternion. A gateway writes entries for a client of 29 without them, in the protocol-29 layout. Because the client window is N and N-1, clients of 28 are refused from protocol 30 on.

Protocol 29 added server events on predicted entities. OwnerState gains a trailing events section, and the pending_inputs of AuthorityTransfer gains a trailing section after state. A gateway sends the OwnerState section only to clients that negotiated 29; a client of 28 is sent the message without it and predicts without the events.

Protocol 28 added portals: five messages, Portals (27), PortalLinks (28) and PortalWarm (29) for clients and gateways and the worker-to-worker PortalAdjacency (51) and PortalHandoff (52), the PortalGhost entity flag, and a trailing link_id on InstancePreparation. The bytes alone are additive, but the meaning is not: a protocol-27 client would be sent replicas of scopes it believes hidden, would never draw them posed, and would predict its pawn into an aperture it does not know is solid, so the window closes.

Protocol 26 added the far relevance tier: a new message, FarEntities, that a worker sends to gateways and a gateway sends only to clients that negotiated 26. Nothing a protocol-25 client sends or reads changed. The worker heartbeat gains hasFarEntities.

Protocol 25 added driven vehicles. A client that drives an entity sends DriveInput, and EntitySpawnBody gains a trailing driver_client_id. A protocol-24 client never sends the message and never reads the field. The entity body nested in AuthorityTransfer always carries the field, so the layout of AuthorityTransfer changed between workers.

Protocol 24 added relevance tiers and dormancy. Nothing a client reads changed. The spawn body's interest_flags carry the entity's relevance priority in bits 1 and 2, and AuthorityTransfer gains trailing update and dormancy sections.

Protocol 22 added forwarded token claims (see Forward token claims to workers). Nothing a client sends or receives changed. Between gateways and workers, SpawnPlayer gains a trailing claims section. Between workers, EntitySpawnBody gains a trailing owner_claims section, which the entity body nested in AuthorityTransfer always carries, so the layout of AuthorityTransfer changed.

Protocol 23 added deliberate session endings: a client's Goodbye, a server's Kicked, a worker's KickPlayer, a server_shutdown field on GatewayDraining and an end_now field on DespawnPlayer. It also added JoinStatus.reason 6 (recovering from a worker failure), the EntityFlags.OwnerDisconnected bit, and orphan kind 3 in the handover's session section.

Protocol 21 added replicated maps (see Replicated maps): the EntityMaps and GhostMaps messages and the trailing maps field of EntitySpawnBody.

Protocol 20 added sync audiences: a behavior's sync state can be limited to some clients (see Private sync state): audience bits and the Cleared chunk in sync state, the SyncAudience message, and trailing fields in EntitySpawnBody and EntitySyncBody.

Protocol 19 changed no byte layout. It changed what a behavior index names: an entity carrying a container no longer has a behavior for it, so every network behavior that came after that one on the entity moved down by one index. RPCs, network variables, and sync state are addressed by that index. A protocol-18 peer would address the wrong behavior on such an entity, which is why protocol 18 is outside the window.

  • A client outside the window is refused with JoinRejected, code 3, and the gateway's supported range, then disconnected. It is not dropped silently.
  • A gateway or worker peer whose version differs is disconnected, and both sides log the two versions.
  • A sender MUST preserve the message identifiers, field order, primitive encodings, and field meanings defined here. Fields explicitly described as optional may be omitted at the end of a message.
  • The gateway returns the client's accepted version in Welcome.negotiated_version.
  • The protocol does not include a game-schema identifier. Every peer MUST use matching prefab identifiers, behavior order, NetworkVariable order and types, remote procedure call (RPC) methods, input types, and custom sync-state formats. Hello.game_content_version is a number the game chooses for its own content; Nebula compares it and refuses a mismatch, and gives it no other meaning.

For process replacement and compatibility requirements, see Upgrade a running mesh.

Unsigned tick and entity-epoch comparisons do not include wraparound handling. A process that remains up across a u32 wrap is outside the current compatibility contract.

Transport contract

Nebula uses LiteNetLib over User Datagram Protocol (UDP). A connection request uses the plaintext key nebula-v1. This key is a protocol discriminator, not a credential.

Nebula deliveryLiteNetLib channelLiteNetLib methodContract
Sequenced0SequencedUnreliable and newest-wins. A packet larger than the peer's maximum transmission unit cannot be fragmented and is dropped.
ReliableOrdered1ReliableOrderedRetried and delivered in order. LiteNetLib may fragment it.

Except for Batch, one decoded application payload contains one complete Nebula message. The first byte is its MsgId. Encrypted UDP connections wrap these payloads in an encryption envelope; the layouts below describe the plaintext after decryption. A sender MUST use the delivery class listed below; the receiver does not derive or verify the class from the payload.

Web clients

A client built for the web connects to the gateway over WebRTC data channels instead of LiteNetLib. It sends its Session Description Protocol (SDP) offer as the body of POST /nebula/rtc on the gateway's web port and applies the SDP answer from the response. The gateway answers as an ICE-lite endpoint with DTLS role passive and lists its host candidates. Both data channels are created with negotiated: true, and every message is binary.

Nebula deliveryData channelOptionsContract
Sequencedid 1ordered: false, maxRetransmits: 0Each message starts with a u16 sequence number that increases by one per message and wraps. A receiver MUST drop a message whose number is not newer than the newest it delivered; a number is newer when it differs and (number - newest) mod 65536 is below 32768. The Nebula message follows the two bytes.
ReliableOrderedid 0reliable, orderedOne data channel message contains one Nebula payload, as on LiteNetLib.

Configure client encryption and protect service links

Native UDP client connections are plaintext unless the client enables encryption and the gateway supports it. Set RequireEncryption to refuse plaintext clients and configure a client certificate fingerprint to authenticate the gateway. WebRTC encrypts browser connections with Datagram Transport Layer Security (DTLS). See Encrypt client connections.

Gateway-to-worker and worker-to-worker traffic remains unencrypted and requires a trusted private network. These peers authenticate only when a mesh token is configured. Client identities and session tokens are checked separately; see Authentication.

Primitive encodings

All fixed-width numbers are little-endian. No field is naturally aligned and no padding bytes are inserted.

TypeBytesEncoding
u8, s81Unsigned byte or two's-complement signed byte.
u16, s162Unsigned or two's-complement signed integer.
u32, s324Unsigned or two's-complement signed integer.
u64, s648Unsigned or two's-complement signed integer.
f162IEEE 754 binary16.
f324IEEE 754 binary32.
f648IEEE 754 binary64.
bool1Writers emit 0 for false and 1 for true. Readers treat any nonzero value as true.
stringvariableA u16 prefix n, followed by n - 1 UTF-8 bytes. n = 0 is null and n = 1 is empty. At most 65,534 UTF-8 bytes.
bytesvariableA u16 byte count followed by that many bytes. Null and empty both encode as a zero count. At most 65,535 bytes.
vector28f32 x, then f32 y.
vector312f32 x, f32 y, then f32 z.
quaternion16f32 x, f32 y, f32 z, then f32 w.
color16f32 r, f32 g, f32 b, then f32 a.

An enum written through NetworkSerialization is an s32, but protocol enums and flags use the field width shown in each layout.

Compressed quaternion

A compressed quaternion occupies one u32. Normalize the quaternion, find the component with the greatest absolute value, and negate all components when that component is negative. Ties select the earliest component in x, y, z, w order. Bits 31–30 contain the omitted component's index (x = 0, y = 1, z = 2, w = 3). The remaining components, in x, y, z, w order with the selected component omitted, occupy bits 29–20, 19–10, and 9–0.

For each stored component v, encode:

r = 1 / sqrt(2)
q = clamp(round_to_nearest(((clamp(v, -r, r) + r) / (2 * r)) * 1023), 0, 1023)

Decode each ten-bit value with (q / 1023) * (2 * r) - r. Reconstruct the omitted, nonnegative component as sqrt(max(0, 1 - a*a - b*b - c*c)).

Precise compressed quaternion

Protocol 30 adds a precise form of six bytes: a u16 holding bits 47–32, then a u32 holding bits 31–0 of a 48-bit value. Bits 46–45 hold the omitted component's index, chosen and signed as for the compressed form, and bits 44–30, 29–15 and 14–0 hold the remaining components, each quantised to fifteen bits with 32767 in place of 1023. Bit 47 is zero. The error is about 0.005°.

Shared identifiers and structures

Entity identifiers and epochs

net_id is a u64. A worker allocates it as (worker_index << 48) | sequence, where worker_index supplies the high 16 bits and sequence increases from 1 in the low bits. Zero means that no entity is assigned.

An entity authority epoch is a u32 and starts at 1. It increases on every handover. Receivers ignore entity state whose epoch is older than the newest epoch they have accepted. This is separate from the u64 lease epoch in the control-plane contract.

client_id is a u64 session identifier that is unique across every gateway of a mesh. A gateway draws a random 32-bit incarnation when it starts and numbers its sessions from 1: client_id = (incarnation << 32) | sequence. Zero means no owner in an entity record and broadcast in an EntityRpc. The gateway replaces the client_id supplied by a client in ClientInput, DriveInput and ServerRpc with the session ID of that connection. A session ID stays the same when the client reconnects with its session token, through any gateway of the mesh (see Hello and session messages).

Container reference

A ContainerRef starts with a u16 index.

IndexMeaningFollowing bytes
0 through 65532Static container's dense registry index.None.
65533Runtime container registered by the game.u64 runtime_id.
65534Dynamic container carried by an entity.u64 carrier_net_id.
65535No container.None.

Positions and rotations are relative to this container unless a behavior's documented payload says otherwise. A peer may receive a dynamic or runtime reference before it can resolve the container; Nebula holds affected entity messages until that container arrives.

Role and entity flags

FieldValueMeaning
PeerRole.Unknown0Unidentified peer.
PeerRole.Client1Game client.
PeerRole.Gateway2Gateway service.
PeerRole.Worker3Simulation worker.
HelloFlags.Bot1The client is a headless bot.
EntityFlags.OwnerIsBot1The owning client is a bot.
EntityFlags.ServerDriven2The entity has no client owner and is driven by a worker.
EntityFlags.OwnerDisconnected4The owning client is disconnected and the worker is keeping the entity for the reclaim grace. The worker announces the entity again, in place, when the bit changes.
EntityFlags.PortalGhost8A ghost spawn only: the sender ghosts the entity because it is in the band of a portal face whose other side the receiver holds. The receiver keeps it as a proxy only, posed through the link in its own scene.

Unknown flag bits are reserved. A version 30 sender MUST write them as zero.

Message registry

The direction abbreviations are client (C), gateway (G), and worker (W). W-W means a worker-to-worker link. “Conditional” means the payload selects the channel as described by its layout.

IDNameDirectionDeliveryBody layout
1HelloC-G, G-W, W-WReliable orderedHello
2WelcomeG-CReliable orderedWelcome
3PingC-GSequencedPing
4PongG-CSequencedPong
5JoinStatusG-CReliable orderedJoinStatus
6JoinRejectedG-CReliable orderedJoinRejected
7GatewayDrainingG-CReliable orderedGatewayDraining
8SessionReplacedG-CReliable orderedSessionReplaced
9KickedG-CReliable orderedKicked
10EntitySpawnW-G-CReliable orderedEntitySpawnBody
11EntityDespawnW-G-CReliable orderedEntityDespawnBody
12EntityVarsW-G-CReliable orderedEntityVarsBody
13EntityRpcW-G-CReliable orderedEntityRpcBody
14WorldStateW-G-CConditionalStateBatchBody
15OwnerStateW-G-C to ownerSequencedOwnerState
16ContainerOwnershipG-CReliable orderedContainerOwnership
17EntityStateW-G-CConditionalEntitySyncBody
18BatchG-CReliable orderedBatch
19InstancePrepareW-W, W-G-CReliable orderedInstancePreparation
22InstanceReadyW-W, C-G-WReliable orderedInstancePreparation
20ClientInputC-G-WSequencedClientInputBody
21ServerRpcC-G-WReliable orderedEntityRpcBody
23ClientFocusHintC-GSequenced (hint), ReliableOrdered (clear)ClientFocusHint
24GoodbyeC-GReliable orderedGoodbye
25DriveInputC-G-W, W-WSequencedDriveInputBody
26FarEntitiesW-G-CReliable orderedFarEntities
27PortalsW-G-CReliable orderedPortals
28PortalLinksW-G-CReliable orderedPortalLinks
29PortalWarmC-G-WReliable orderedPortalWarm
30SpawnPlayerG-WReliable orderedSpawnPlayer
31DespawnPlayerG-WReliable orderedDespawnPlayer
32EndSessionW-GReliable orderedEndSession
33InterestSubscribeG-WReliable orderedInterestSubscribe
34InterestResyncW-GReliable orderedInterestResync
35EntityRedirectW-GReliable orderedEntityRedirect
36EntityForgetW-GReliable orderedEntityForget
37SyncAudienceW-GReliable orderedSyncAudience
38EntityMapsW-G-CReliable orderedEntityMapsBody
39KickPlayerW-GReliable orderedKickPlayer
40GhostSpawnW-WReliable orderedEntitySpawnBody
41GhostStateW-WConditionalStateBatchBody
42GhostVarsW-WReliable orderedEntityVarsBody
43GhostDespawnW-WReliable orderedEntityDespawnBody
44AuthorityTransferW-WReliable orderedAuthorityTransfer
45ForwardInputW-WSequencedClientInputBody
46AuthorityRpcW-WReliable orderedAuthorityCall
47GhostSyncStateW-WConditionalEntitySyncBody
48WorkerMessageW-WCaller-selectedWorkerMessage
49AuthorityRpcReplyW-WReliable orderedAuthorityCallReply
50GhostMapsW-WReliable orderedEntityMapsBody
51PortalAdjacencyW-WReliable orderedPortalAdjacency
52PortalHandoffW-WReliable orderedPortalHandoff

IDs not listed in this table are reserved. A version 30 sender MUST NOT use them.

Message layouts

The layouts below omit the leading u8 msg_id. Fields appear in wire order. Repeated structures have no padding.

Hello and session messages

Hello           := u16 protocol_version, u8 role, string id, u32 index, u8 flags,
                   string token, string session, u32 incarnation, string scope_key,
                   u32 game_content_version
Welcome         := u64 client_id, u8 tick_rate, u32 server_tick, string identity,
                   string token, string session_token, bool reclaimed,
                   u16 negotiated_version
JoinStatus      := u8 state, u16 estimated_seconds, u8 reason
JoinRejected    := string reason, bool retry, u8 code, f16 saturation,
                   u16 supported_min_version, u16 supported_max_version,
                   u32 server_content_version
GatewayDraining := u16 reconnect_within_seconds, bool server_shutdown
SessionReplaced := string reason
Kicked          := u16 code, string reason
Goodbye         := (no body)
Ping            := f64 client_time
Pong            := f64 client_time, u32 server_tick

For a client, Hello.id is the requested player name, index is zero, flags may include Bot, token is an OpenID Connect ID token, a token from an earlier Welcome, or empty to ask for a new anonymous identity, session is the session_token from the last Welcome or empty on a first connection, and incarnation is zero.

scope_key is the simulation scope the client asks to be placed in: the opaque key the game chose, or empty for the public world. The gateway spawns the player only into a container whose scope key is ordinally equal to it, and holds the join in JoinStatus.state 1 while no such container has a live owner; it never falls back to another scope. The gateway does not activate the scope, so the key must already have been activated through the control plane. If Hello ends before scope_key, the receiver uses an empty key for the public world. It is ignored on a gateway or worker Hello.

game_content_version is the version of the game's own content and rules the client build carries. The gateway compares it with the version it was configured with and refuses a client outside that window with JoinRejected code 4. A gateway configured with 0 does not check. Nebula gives the number no meaning beyond the comparison. If Hello ends before game_content_version, the receiver uses 0. It is ignored on a gateway or worker Hello.

For a worker, id is the worker ID and index is its worker index. A gateway sends its gateway ID with an index of zero. Both send a nonzero incarnation, a random number drawn when the process started, so a peer that sees the same ID twice can tell a restart from a reconnect. When the mesh has a mesh token, token on a gateway or worker Hello is the credential unix_seconds.base64url(HMAC-SHA256(key, role|id|incarnation|unix_seconds)), where key = SHA-256("nebula-peer-auth:" + mesh_token) and role is the decimal PeerRole value. A receiver rejects a credential whose timestamp differs from its clock by more than 300 seconds or whose MAC does not match. Without a mesh token, token is empty and every peer is accepted.

The gateway assigns the client ID, a mesh-wide session ID, and sends the fixed tick rate, currently 60, in Welcome. Welcome.identity is the player's identity: 64 lowercase hex characters, the SHA-256 of the token's issuer and subject. Welcome.token is set only when the client presented no token; it is the anonymous token the gateway issued for that identity, which the client keeps for its next Hello. Welcome.session_token is a signed token naming the session: an HS256 JSON Web Token over the session ID, identity, display name, bot flag, and connection generation, valid for 24 hours. The client keeps it for the life of the process and presents it as Hello.session when it reconnects. A gateway that verifies it, for the identity that just authenticated, welcomes the client with the same client_id and sets reclaimed; the pawn is the same one when the worker still holds it. A session token for another identity, an expired one, or one signed for another mesh is ignored; single-session coordination can still reclaim a session using the authenticated identity. See Authentication.

Welcome.negotiated_version echoes the accepted version from the client's Hello, currently 18. If the field is omitted, it reads as 0 and the client uses the version it announced.

JoinStatus.state is 0 (none), 1 (starting: welcomed, but not placed yet) or 2 (joined). estimated_seconds is a rough wait while starting; 0 means unknown. reason says why a join is being held: 0 none, 1 the world is starting (no worker holds a lease yet), 2 the named scope has not been activated or has no live owner, 3 the scope is restoring after a retire, 4 the scope is retiring, 5 every container the client could be placed in is at capacity and the game's admission hook asked for the client to wait rather than be refused, 6 the worker that held the client's pawn failed and the gateway is placing the client again. The gateway sends that JoinStatus before it despawns the lost pawn. If JoinStatus ends before reason, the receiver uses 1 while starting and 0 otherwise.

A gateway that does not accept a client's token sends JoinRejected with a reason fit to show the player and disconnects the client. retry set means the refusal is about the gateway, not the client: it is draining, and the client SHOULD reconnect after a short delay, which a load balancer then hands to another gateway. retry clear means the client must wait for the player to retry. This includes refused credentials and a session takeover whose previous connection could not be disconnected.

code is the refusal in typed form: 0 none (read reason and retry), 1 the target is at capacity and the admission hook refused the arrival, 2 the admission hook refused this particular arrival although the target had room. saturation is how full the target was for code 1: the dominant cost component's share of its own budget, 1.0 being the whole of it; 0 when the gateway did not say. A client that receives code 1 or 2 MUST NOT reconnect on a timer: the refusal is neither about its credentials nor about this gateway, and the game decides what happens next (a queue, another instance, a different scope). If JoinRejected ends before code or saturation, each omitted field defaults to 0.

code 3 means the client's protocol is outside the gateway's window and code 4 means its game_content_version is not one the gateway runs. The client stops automatic retries for either code and preserves its saved identity. The game can select a compatible server or ask the player to use compatible content before reconnecting. supported_min_version and supported_max_version are the window the gateway accepts and server_content_version is the content version it runs; all three are sent on every refusal, whatever the code, and are 0 from a gateway that does not write them. For code 3, a client below supported_min_version needs updating and one above supported_max_version has reached a server that has not been upgraded yet. code 5 means the gateway accepts encrypted client links only (RequireEncryption) and this link is in the clear; the client must turn encryption on before trying again (see Encrypt client connections).

GatewayDraining tells a connected client that the gateway is leaving service. The client SHOULD reconnect within reconnect_within_seconds, presenting its session token, so the session and pawn carry over to the gateway that answers. A gateway also sends it when its process stops. server_shutdown set means that no other gateway was serving when it stopped: the server is going away or restarting, and the client SHOULD reconnect on its normal retry schedule rather than at once.

Kicked tells a client that the server removed its player. code is a number the game defines and Nebula passes through unchanged; reason is fit to show the player. The session is over: the gateway has told the pawn's worker to remove it and closes the link right after sending this. The client SHOULD NOT reconnect by itself, and SHOULD discard its session token.

Goodbye tells the gateway that the player is leaving for good. The gateway ends the session at once: it tells the pawn's worker to remove the pawn with DespawnPlayer.end_now, releases the session so the player's next sign-in starts a new one without waiting, and closes the link. The close is the client's acknowledgement. A client SHOULD keep the link open after sending Goodbye until the gateway closes it or a short timeout passes.

SessionReplaced tells a client that the player it signed in as connected again elsewhere and that its connection must close before the replacement can hold the session and pawn. The gateway closes the link after sending it. The client SHOULD show reason, discard its session token, and stay out of the world until the player asks to return; reconnecting on its own would take the session back from the other connection, which would then take it back in turn. See One connection per player.

Pong.client_time echoes Ping.client_time. server_tick is the gateway's current derived tick.

Entity lifecycle and variables

EntitySpawnBody :=
  u64 net_id,
  u16 prefab_id,
  u64 owner_client_id,
  ContainerRef container,
  u32 entity_epoch,
  u16 owner_worker_index,
  vector3 local_position,
  quaternion local_rotation,
  vector3 local_scale,
  vector3 velocity,
  u8 entity_flags,
  u32 scene_id,
  bytes vars,
  bytes state,
  string owner_identity,
  f16 relevance_radius,
  u8 interest_flags,
  u8 interest_group,
  u16 view_seq,
  u32 cohesion_group,
  f16 cost_weight,
  [u32 audience_generation,
   bytes audience_sets,
   [u32 maps_length, raw maps[maps_length],
    [Claims owner_claims,
     [u64 driver_client_id]]]]

Claims := u8 count, ClaimEntry[count]
ClaimEntry := string name, string value

EntityDespawnBody := u64 net_id, u32 entity_epoch, u16 view_seq
EntityVarsBody    := u64 net_id, u32 entity_epoch, bytes vars

EntitySpawn and GhostSpawn share EntitySpawnBody. A nonzero scene_id tells the receiver to bind its matching scene object instead of instantiating prefab_id. A spawn for a known net_id updates that entity's owner, epoch, container, variables, and sync state; it does not create a duplicate. owner_identity is the owning player's identity (see Welcome), or empty for an entity no player owns.

cost_weight is what the game says the entity costs to simulate, as a multiplier on its category weight in CostWeights (see Container cost). It travels so the worker an entity hands over to reports the same cost for it. 0 means "no opinion": the receiver keeps the weight it already has.

relevance_radius, interest_flags and interest_group come from the entity's prefab and tell a gateway that has no prefabs how to scope the entity: relevance_radius is how far clients hear about it in meters (0 means the mesh default, and the gateway clamps the value to InterestMaxRadius), interest_flags bit 0 (AlwaysRelevant) puts it in every client's set, bits 1 and 2 hold the entity's relevance priority (0 Normal, 1 High, 2 Low, 3 Background) that the gateway uses to pick each client's update rate, and interest_group is a number a game's interest policy may filter on. Other flag bits are reserved. A client ignores the priority bits.

cohesion_group is the entity's cohesion group, or 0 for none: the game's declaration that every entity carrying the same non-zero value is simulated by one worker and moves between workers as a unit. Nebula only carries the number; the ids are the game's own. Because the field travels in EntitySpawn, GhostSpawn and AuthorityTransfer, every process holding a copy of the entity knows the group.

view_seq is assigned by the gateway, per client and per entity, and increases every time the entity enters that client's set. A client MUST ignore an EntityDespawn whose view_seq is older than the view it currently holds for that net_id, so a despawn delayed behind a re-entry cannot remove the replica the client has just been given. Workers write 0 in both messages; only the gateway-to-client link uses the field.

audience_generation and audience_sets are the entity's sync audience state, written only by workers. A standalone EntitySpawn or GhostSpawn carries the section only when the generation is non-zero or the entity has a Custom audience. The entity body nested in AuthorityTransfer always carries it, because fields follow it there. A gateway MUST NOT forward the section's contents to a client: when maps follows, it writes 0 and empty sets.

maps is every replicated map of the entity in full, as a MapSection. A standalone spawn carries it only when the entity has maps, and then always writes the audience section before it. The entity body nested in AuthorityTransfer always carries it, empty when there are none. A gateway keeps it current with each EntityMaps and sends the current contents in the spawn a client joining later receives.

owner_claims is the owning player's forwarded token claims, written only between workers: in a GhostSpawn when the owner has claims, and always in the entity body nested in AuthorityTransfer (count 0 when there are none). A standalone spawn that carries it writes the audience and maps sections before it. A worker MUST NOT send claims to a gateway, and a gateway MUST NOT forward them to a client: when driver_client_id follows, they write the section with count 0.

driver_client_id is the client that drives the entity (see Drive a vehicle), or 0 for none. Only an entity no client owns can have a driver. A spawn carries the field when it is not zero, with every earlier trailing section written before it, and the entity body nested in AuthorityTransfer always carries it. A worker announces a change of driver with a fresh EntitySpawn to every gateway that holds the entity. The gateway forwards DriveInput for the entity only from this client and keeps the entity in this client's set. A client that finds its own session ID here predicts the entity.

The vars blob concatenates every NetworkVariable in deterministic behavior and reflected field order. It carries no count, names, type tags, or per-value lengths. A compatible game schema is therefore required. state is a sync-state envelope.

Behavior sync state

EntitySyncBody :=
  u64 net_id,
  u32 entity_epoch,
  u32 tick,
  ContainerRef container,
  bool reliable,
  bytes chunks,
  [u32 audience_generation]

EntityState and GhostSyncState share this body. A sender MUST use reliable ordered delivery when reliable is true and sequenced delivery when it is false. chunks contains a sync-state envelope. A worker writes audience_generation for an entity that has a behavior with an audience other than Everyone; a gateway MUST NOT forward it to a client.

Sync-state envelope

SyncStateEnvelope := u8 chunk_count, Chunk[chunk_count]
Chunk             := u8 behavior_index, u8 flags, u16 byte_length, raw[byte_length]

Chunk flag bit 0 (Full) marks a keyframe. Bits 1–2 hold the behavior's sync audience: 0 Everyone, 1 Owner, 2 WorkersOnly, 3 Custom. Bit 3 (Cleared) is sent only from a gateway to a protocol-20 client, on an empty chunk, and tells the client it has left the behavior's audience. Bits 4–7 are reserved. The raw chunk is defined by that behavior's WriteSyncState and ReadSyncState pair. Receivers skip an unknown behavior index and bound each reader to byte_length.

Spawn snapshots contain a full chunk for every sync behavior except the root NetworkTransform. A spawn to a gateway leaves out WorkersOnly behaviors. Per-tick sequenced sync streams send a full keyframe every 30 ticks. The root transform uses WorldState and GhostState instead.

Sync audiences

SyncAudience :=
  u64 net_id,
  u32 entity_epoch,
  u32 audience_generation,
  bytes audience_sets

AudienceSets := u8 set_count, AudienceSet[set_count]
AudienceSet  := u8 behavior_index, u16 member_count, u64 client_id[member_count]

An audience limits which clients receive a behavior's chunks. It never limits workers: GhostSyncState, GhostSpawn and AuthorityTransfer carry every behavior. The gateway filters each chunk per client on every path to a client: EntitySpawn, EntityState on either channel, and the spawns it builds from its cached keyframes. A chunk goes to a client when its audience is Everyone; when it is Owner and the client is the entity's owner_client_id; or when it is Custom and the client is in the behavior's set. A WorkersOnly chunk never goes to a client.

A worker sends SyncAudience when the member set of one of an entity's Custom behaviors changes, before that tick's sync chunks, to every gateway it sends the entity's sync state to. It carries every set of the entity, sorted, at most 256 members each. audience_generation increases with every change and travels with the entity through GhostSpawn and AuthorityTransfer. A gateway MUST NOT send a client a Custom chunk whose EntitySyncBody.audience_generation is newer than the sets it holds. When a set changes, the gateway sends each client that joined it the newest keyframe it holds for that behavior, as a reliable EntityState, and sends each protocol-20 client that left it a reliable EntityState holding one empty Cleared chunk for the behavior. It does the same for Owner behaviors when an EntitySpawn names a different owner.

Replicated maps

EntityMapsBody := u64 net_id, u32 entity_epoch, u32 maps_length, raw maps[maps_length]

MapSection := u8 map_count, MapEntry[map_count]
MapEntry   := u8 map_index, u32 body_length, MapBody
MapBody    := u8 version (1), u8 flags, u32 entry_count, MapOp[entry_count]
MapOp      := u8 op, bytes key, [bytes value]

A NetworkMap field sends only what changed. map_index is the map's position among the entity's NetworkMap fields, in declaration order. flags bit 0 (clear) tells the receiver to empty the map before applying the operations. op 1 sets key to value; op 0 removes key and carries no value. key and value are the bytes NetworkSerialization writes for the map's types, so a peer without the game's types, such as the gateway, can hold them. A full copy is a body with clear set and every entry as a set.

The authority sends EntityMaps to gateways and GhostMaps to ghost workers in the tick a map changes, after that tick's EntityVars, with one operation per changed key holding its final state. Applying a body to a copy that already contains its changes leaves the copy unchanged. A receiver MUST ignore a message whose entity_epoch is older than its copy. A gateway applies each EntityMaps to its copy of the entity's maps before it relays it, and sends it only to clients that negotiated protocol 21.

Remote procedure calls

EntityRpcBody :=
  u64 net_id,
  u32 entity_epoch,
  u8 behavior_index,
  u32 method_hash,
  u64 client_id,
  f32 radius,
  bytes args

EntityRpc and ServerRpc share this body. method_hash is 32-bit FNV-1a over the RPC method name's UTF-16 code units: start at 2166136261, then for each code unit XOR its value and multiply by 16777619 with u32 overflow. Overloads are not supported.

Arguments are concatenated in declaration order using NetworkSerialization; there is no argument count or type tag. EntityRpc.client_id = 0 broadcasts to all clients, while a nonzero value selects one client. radius applies to an EntityRpc broadcast: a nonzero value delivers it only to clients whose pawn is within that many meters of the entity. It is written as 0 on a targeted EntityRpc and on ServerRpc. The gateway overwrites ServerRpc.client_id with the sending connection's session ID and only forwards a call for an entity that connection owns. A worker accepts a ServerRpc for a session only from the gateway that currently holds that session.

Authority calls

AuthorityCall :=
  u64 call_id,
  u8 hops,
  u8 flags,
  u64 net_id,
  u32 entity_epoch,
  u8 behavior_index,
  u32 method_hash,
  bytes args

AuthorityCallReply :=
  u64 call_id,
  u8 outcome,
  u32 entity_epoch,
  u8 hops

AuthorityRpc carries an AuthorityCall: an RPC from a worker that holds a ghost of the entity to the worker it believes has authority. method_hash and args are encoded as in EntityRpcBody.

call_id is minted by the sending worker: its worker index in the top 16 bits and a per-sender sequence in the low 48. A worker that forwards the call MUST keep call_id and flags, and MUST increment hops. flags bit 0 (WantsReply) asks the deciding worker for an AuthorityCallReply; bits 1–7 are reserved and MUST be written as zero. entity_epoch is the epoch of the sender's copy when it made the call.

The receiving worker decides the call in this order: reject RejectedUnknownEntity (3) when it holds no copy of net_id; reject RejectedStaleEpoch (2) when entity_epoch is greater than its copy's epoch or more than AuthorityCallMaxHops (default 3) below it; apply the call once when it has authority, or reject RejectedDuplicate (5) when it has already applied this call_id; reject RejectedHopLimit (4) when hops has reached AuthorityCallMaxHops; forward to the worker it believes has authority, or reject RejectedUnreachable (6) when it has no connection to one. An applied call reports Accepted (1).

A worker remembers applied call IDs for at most 4096 calls and 600 ticks. Beyond either bound a repeated ID is applied again.

AuthorityRpcReply is sent only when WantsReply was set, to the worker whose index the call_id carries, with the deciding worker's entity_epoch for the entity (0 when it held none) and the hops the call had when decided. outcome 7 (TimedOut) is a sender-side value and MUST NOT appear on the wire.

Batched transform state

StateBatchBody   := u32 tick, u16 worker_index, u16 entry_count, EntityStateEntry[entry_count]
EntityStateEntry := u64 net_id, u32 entity_epoch, ContainerRef container,
                    u16 fields, u8 ext (protocol 30 and later), conditional_transform_fields

WorldState and GhostState share this body. Writers group entries with the Reliable flag into reliable ordered packets and all other entries into sequenced packets. A packet MUST NOT mix the two classes.

The fields mask controls the rest of each entry:

BitHexNameMeaning
00x0001PositionXInclude position X.
10x0002PositionYInclude position Y.
20x0004PositionZInclude position Z.
30x0008RotationXInclude Euler X, or indicate that rotation is present in quaternion mode.
40x0010RotationYInclude Euler Y, or indicate that rotation is present in quaternion mode.
50x0020RotationZInclude Euler Z, or indicate that rotation is present in quaternion mode.
60x0040ScaleXInclude scale X.
70x0080ScaleYInclude scale Y.
80x0100ScaleZInclude scale Z.
90x0200VelocityInclude all three velocity axes as f16.
100x0400TeleportClear interpolation and snap. Adds no bytes.
110x0800HalfEncode selected position, Euler rotation, and scale axes as f16 instead of f32.
120x1000QuaternionEncode rotation as one quaternion rather than selected Euler axes.
130x2000CompressedWith Quaternion, use the compressed u32 form.
140x4000ReliableSend this entry on reliable ordered delivery. Adds no bytes.
150x8000LocationComplete location keyframe, including a container or epoch change. Snap and accept the supplied local pose. Adds no bytes.

After fields, write data in this order:

  1. Selected position axes, X then Y then Z, as f16 when Half is set and f32 otherwise.
  2. Rotation, only when at least one rotation bit is set. Without Quaternion, write the selected Euler axes in X/Y/Z order and honor Half. With Quaternion | Compressed, write one compressed u32. With Quaternion | Half, write four f16 components. With Quaternion alone, write a normal quaternion.
  3. Selected scale axes, X then Y then Z, honoring Half.
  4. When Velocity is set, write X, Y, and Z as three f16 values regardless of Half.
  5. When ext has AngularVelocity, write the angular velocity's X, Y, and Z, in the container's local frame and radians per second, as three f16 values.

The ext byte is present only between peers of protocol 30 or later. A gateway writing to a client of 29 omits it, writes a precise rotation as the 4-byte compressed form, and omits the angular velocity.

BitHexNameMeaning
00x01AngularVelocityAppend the angular velocity (step 5).
10x02PreciseRotationWith Quaternion | Compressed, write the rotation in the precise form instead of the u32 form.

Bits 2–7 of ext are reserved and MUST be zero.

Prediction messages

ClientInputBody := u64 client_id, u8 frame_count, InputFrame[frame_count]
InputFrame      := u32 tick, bytes payload
DriveInputBody  := u64 client_id, u64 net_id, u8 frame_count, InputFrame[frame_count]

OwnerState :=
  u64 net_id,
  u32 entity_epoch,
  u32 tick,
  u32 last_input_tick,
  s8 input_lead,
  u64 owner_client_id,
  ContainerRef container,
  bytes state,
  [bytes events]

ServerEvents := u8 event_count, ServerEvent[event_count]
ServerEvent  := u32 tick, u8 index, u32 type_id, bytes payload

ClientInput and ForwardInput share ClientInputBody. A worker accepts ClientInput for a session only from the gateway that currently holds that session. The stock client repeats up to its three newest frames to tolerate loss. Each input payload is the exact output of the game's INetworkInput.Serialize method.

DriveInput carries the input of the client that drives net_id. A client writes 0 as client_id. The gateway drops the message unless the entity's latest EntitySpawn names the sender in driver_client_id and no client owns the entity, and forwards it to the worker it believes owns the entity. The worker runs the frames only when it has authority and the sender is the driver it knows; a worker that has handed the entity over forwards the message to the new owner. It never claims the sender's session.

A worker sends OwnerState every tick for an entity a client owns, to that client, and for an entity a client drives, to the driver; owner_client_id names that client. A worker that does not know which gateway holds the driver's session sends it to every gateway it links to, and a gateway forwards it only to that client, and only while the client holds the entity. OwnerState.state is the exact output of the predicted behavior's WriteState. input_lead is the smallest observed input_tick - worker_tick since the previous report, clamped to -127 through 127. -128 means that no input arrived during the reporting window.

events is present only when the worker holds server events for the entity, and a gateway writes it only to a client that negotiated protocol 29. Each ServerEvent is one event the game posted with PostServerEvent: tick is the tick it applies on, index orders the events of one tick (at most 4 per tick, so 0 through 3), type_id is the 32-bit FNV-1a hash of the event type's full name, and payload is the exact output of the event's INetworkSerializable.Serialize. A worker keeps reporting an event in every OwnerState for 30 ticks after its tick, and a client ignores an event it already holds, identified by tick and index. A client reads the section before it reconciles state, so the replay that state starts applies each event on its tick.

Container ownership

ContainerOwnership := u8 flags, u16 entry_count, OwnershipEntry[entry_count],
                      u16 remove_count, string removed_container_id[remove_count],
                      [u16 placement_count, Placement[placement_count],
                       [u16 posed_count, InstancePose[posed_count]]]
Placement := u16 entry_index, string parent_id,
             f64 center_x, f64 center_y, f64 center_z,
             u8 authority, u8 placement_flags
InstancePose := u16 entry_index, f64 origin_x, f64 origin_y, f64 origin_z,
                f32 rotation_x, f32 rotation_y, f32 rotation_z, f32 rotation_w
OwnershipEntry :=
  u16 container_index,
  string container_id,
  u16 worker_index,
  string worker_id,
  u64 lease_epoch,
  string lease_state,
  bool has_instance,
  [InstanceContainerInfo when has_instance],
  u8 entry_flags,
  [vector3 bounds_center, vector3 bounds_size when entry_flags bit 0 is set]

Message flag bit 0 (Full) means the entries are the complete set of containers this client should know about: the client forgets every container the message does not name. Without it the message is a change to what the client already has, and removed_container_id names the containers to forget. A gateway sends the removes after the entities in those containers have been despawned, and sends the upserts before the spawns that need them, so an entity never arrives before its container.

Entry flag bit 0 (Bounds) marks a runtime container and appends its axis-aligned box. Other bits are reserved.

The trailing placement section is optional; a reader stops when the message ends before it. Each Placement completes the runtime entry at entry_index in this message: parent_id names the container it sits in (empty for a root), and the double-precision centre is local to that parent, or absolute for a root. For such an entry the float bounds_center is the same value narrowed and a reader that knows the placement section MUST use the placement instead. authority is 0 (default), 1 (leased), or 2 (inherited). placement_flags bit 0 means the container has a physics frame of its own and bit 1 that its contents are bucketed in the frame's own regions. A sender writes a placement only for an entry that needs one: a child, a framed container, a non-default authority, or a centre a float cannot hold. The trailing instance-pose section follows the placement section (the placement count is then 0 if no entry has a placement). Each InstancePose gives the pose of the instance scope's own space against its host scope for the instance entry at entry_index: a double-precision origin and a rotation. A reader that predates the section stops before it. An empty worker_id represents an unassigned lease. Lease states and transitions are defined by the control-plane contract.

Instance preparation

InstanceContainerInfo := u64 instance_id, string content_resource,
  string scope_key, string part_id
InstancePreparation := u32 request_id, u64 entity_id, ContainerRef destination,
  u16 source_worker, u64 lease_epoch, bool success, u64 link_id

Preparation asks the destination worker and owning client to load content before crossing. link_id is a trailing field: nonzero, the preparation readies a portal link (entity_id is then 0) and only workers exchange it; zero is one entity's crossing. A body that ends before it reads as zero. The gateway routes requests only for a pawn owned by the sending worker and routes replies only for the sending client's pawn. The source validates the request, destination lease, and authority before committing.

scope_key is the opaque instance key the game chose (TemplateId/key); instance_id is its 64-bit hash. A receiver MUST NOT parse scope_key; it is compared with ordinal string equality only. The entity location contract defines how it names an entity's scope. A sender MUST write it on the wire; only the Base64 copy stored on a control-plane lease row written by an earlier release may end before it, in which case it reads as empty.

part_id says which part of its scope this container is: the ScopePart.PartId the scope was activated with. For a chunk of a scoped chunk grid it is the chunk's coordinate, written as c/x/y/z, which is what lets a receiver place a container whose id is a hash of the scope key rather than a packed coordinate. A receiver MUST treat any other value as opaque. It is written after scope_key and read with the same tolerance: a message or stored row that ends before it reads as empty, which is what every row written before protocol 18 does.

Portals

Portals := u16 upsert_count, PortalRecord[upsert_count], u16 remove_count, u64[remove_count] portal_id
PortalRecord := u64 portal_id, string id, u64 near_scope, string near_container_id, ContainerRef near_container,
  double3 near_origin, f32x4 near_rotation, u8 far_kind, u64 far_scope, double3 far_origin, f32x4 far_rotation,
  string template_id, u8 aperture_kind, f32 half_width, f32 half_height, f32 band_margin, f32 band_depth,
  f32 window_margin, f32 window_depth, u8 flags
PortalLinks := u16 upsert_count, PortalLinkRecord[upsert_count], u16 remove_count, u64[remove_count] link_id
PortalLinkRecord := u64 link_id, u64 portal_id, u64 party_hash, u64 far_scope, string far_entry_id, ContainerRef far_entry,
  u8 state, u8 wait, u64 wait_client, u8 progress, u32 generation, u8 flags,
  u16 member_count, u64[member_count] members, u16 observer_count, u64[observer_count] observers
PortalWarm := u64 link_id, u32 generation, u64 client_id

A portal is a way between two scopes. A worker sends Portals for the portals whose container it owns, and PortalLinks for the state of each party's link through them; it sends both to every gateway when a gateway connects and again whenever a record changes, a portal's records before its links. portal_id is the 64-bit FNV hash (ScopeKeys.Hash) of the near container's id, # and the portal's authored id; link_id is a hash of the portal id and the party; only party_hash of the party travels. far_kind 0 is a fixed far scope (far_scope and the far face), 1 a template (template_id; the far scope of each link is in the link). state is 0 closed, 1 preparing, 2 open, 3 closing. wait is 0 none, 1 far scope activating, 2 far worker content, 3 far entities restoring, 4 client warming, 5 far worker handshake, 6 band occupied, 7 far scope retired, 8 near container moving. flags of a link are bit 0 observer, bit 1 member, bit 2 observer-anyone. progress is 0 to 255.

To a gateway a link carries its members (the clients that must be warm) and its observers (the clients that may look through); a gateway keeps them and enforces the observer list without running game code. A gateway sends a client a portal's record and its links on the reliable stream behind the container row of either face, so the receiver already holds the row, and withdraws them when the row is withdrawn or the portal removed. To a client the two lists are empty and flags says whether that client is a member and whether it is an observer. A link is sent to a member only once it holds the far entry container's row, which the gateway keeps sent for it while the link is live. A receiver MUST NOT parse a portal's authored id.

A member client prepares the far entry container and, once its content is ready, sends PortalWarm with the link's generation. The gateway forwards it to the worker that owns the portal only from a member of the link, with client_id set to the sending client's id (a client writes 0). The worker ignores a warm whose generation is not the link's current one.

Portal adjacency and handoff

Workers exchange these two messages so that the workers on either side of a portal agree on its links. A client and a gateway never see them.

PortalAdjacency := u8 removed, u64 link_id,
  [u32 generation, u8 state, u64 far_scope, PortalRecord portal, ContainerRef near_container,
   u16 far_container_count, ContainerRef[far_container_count] far_containers,
   u16 far_row_count, ContainerRef[far_row_count] far_rows when removed is 0]
PortalHandoff := u64 portal_id, string near_container_id, u16 link_count, HandoffLink[link_count]
HandoffLink := string party, u32 generation, u8 state,
  u16 member_count, u64[member_count] members,
  u16 warm_count, u64[warm_count] warm,
  u16 observer_count, u64[observer_count] observers

A worker sends PortalAdjacency for a link it drives to the workers that own the containers of the far scope the link's band touches. It tells the receiver that a body in the band of a face it does not own is to be ghosted across, and lets the far worker build its side of the aperture before the link opens. far_containers are the containers the far band touches; far_rows are the other containers the far window touches, which a receiver that owns only those prepares but ghosts nothing for. A message with removed set to 1 ends after link_id and withdraws the link. A body that ends before far_row_count reads it as empty.

A worker sends PortalHandoff to the worker that takes a portal's near container on a planned reassignment, together with the release. It carries each party's link state, members, warm clients and observers; everything else is worked out again from the portal's content and the leases. After a crash nothing is sent and the links read closed.

Reliable message batch

Batch := u16 message_count, BatchedMessage[message_count]
BatchedMessage := u16 byte_length, raw_message[byte_length]

Each raw_message is a complete Nebula message, including its u8 msg_id, and its length MUST fit in a u16. The gateway uses Batch only for reliable gateway-to-client traffic. It flushes at about 1,100 bytes or by the end of the current transport poll, preserving reliable-message order.

Player lifecycle

SpawnPlayer   := u64 client_id, ContainerRef container, string name, bool is_bot, string identity, u64 generation,
                 [Claims claims]
DespawnPlayer := u64 client_id, u64 generation, bool end_now
EndSession    := u64 client_id, u64 generation, string reason
KickPlayer    := u64 client_id, u64 generation, u16 code, string reason

The gateway selects a currently assigned container and asks its worker to spawn the player. identity is the player's identity as the gateway established it from the client's Hello. name is the verified token's NameClaim when the gateway has one set and the token carries it, and otherwise the name in the Hello. The game supplies the actual spawn behavior.

claims holds the claims of the player's verified OpenID Connect token that NebulaConfig.ForwardedClaims lists, each value as a string (a boolean is true or false, a number is formatted as JSON writes it). It is empty for an anonymous player. A section holds at most 16 entries; a name is at most 64 characters and a value at most 256, and a receiver MUST reject a section that breaks these limits. The claims are taken from the token presented on the connection that sent the claim, so a reclaim replaces what the worker held. A worker puts them on every entity the player owns and sends them on in owner_claims.

SpawnPlayer is also the message by which a gateway claims a session. generation is the session's connection generation: every time a client connects or reconnects, the gateway sets it to a value newer than any the session had before. A worker that already holds a pawn for client_id re-announces it to the sender with EntitySpawn and, when generation is newer than the one it holds, routes the session's input and RPCs through the sender from then on. A worker MUST ignore a SpawnPlayer or DespawnPlayer whose generation is older than the one it holds. This fences a gateway that lost the client from undoing what the gateway that gained it did.

On DespawnPlayer, or when a gateway's link is lost, a worker keeps the pawn for NebulaConfig.SessionReclaimSeconds (default 30) and despawns it afterwards unless a newer SpawnPlayer reclaimed the session. With the setting at 0 it despawns the pawn at once.

DespawnPlayer.end_now set means the session is over rather than disconnected: the player said Goodbye or was kicked. The worker despawns the pawn at once, whatever SessionReclaimSeconds is. A worker that has handed the pawn to another worker forwards the message there, end_now included; a worker that receives it before the pawn arrives in a handover keeps the end, for the reclaim grace and at least 10 seconds, and removes the pawn when it arrives. The field is optional and written only when set.

KickPlayer asks the gateway that speaks for a session to remove the player: the gateway sends the client Kicked with the same code and reason, ends the session, and closes the link. A gateway MUST ignore it when its own generation for the session is newer. The worker that sends it ends the session itself, so the gateway does not send that worker a DespawnPlayer. A worker that has no link to the session's gateway, or whose session is waiting for a reclaim, holds the kick for the reclaim grace: it sends it when that gateway links again, and answers a SpawnPlayer claiming the session with KickPlayer, at the claim's generation, instead of a pawn.

A mesh with NebulaConfig.SingleSessionPerPlayer set, the default, coordinates admission through the control plane before sending Welcome. The previous gateway MUST disconnect its client and acknowledge release before the coordinator grants the replacement. If that acknowledgment cannot be obtained, the new connection MUST be rejected. The coordinator preserves the session ID, increments its generation, and retains the pending spawn worker assignment. Workers fence stale generations; they do not resolve duplicate identities. A worker whose session is claimed by a different gateway sends EndSession to the previous gateway, naming the claim generation. That gateway MUST ignore an older generation and MUST NOT despawn the reclaimed pawn. With single-session coordination disabled, independent connections get separate sessions; a valid session token still reclaims its named session.

Authority transfer

AuthorityTransfer :=
  EntitySpawnBody entity,
  u32 new_epoch,
  bytes pending_inputs,
  bytes handover_state,
  u16 ghost_worker_count,
  string ghost_worker_id[ghost_worker_count],
  u64 session_generation,
  string session_gateway,
  u16 interest_gateway_count,
  string interest_gateway_key[interest_gateway_count],
  [u8 crossing,
   [u8 session_orphan, f32 session_reclaim_remaining,
    [u8 extent_source, vec3 extent_center, vec3 extent_size,
     [u8 update_interval,
      [u8 dormancy, f32 sleep_when_unobserved]]]]]

crossing is an optional trailing byte. The sender writes it when it is 1, and also, as 0, when the session section follows. It means the sender is handing the entity over for a physics frame crossing that it may not perform itself, because the receiver simulates the frame's carrier. The entity's pose is still in the sender's coordinates. The receiver crosses it on its next tick and does not hand it back for a short hold (30 ticks), so a lagging copy of the frame on the sender cannot return it at once.

interest_gateway_key lists the gateways that were following the entity on the old owner without subscribing its region — an explicit per-entity subscription, or the session the gateway speaks for. The old owner also sends each of them an EntityRedirect; the new owner announces the entity's spawn to them in addition to the gateways its own subscribed regions cover, so a followed entity is not lost when it changes worker.

session_generation and session_gateway describe the owner's session as the sender knew it: the connection generation and the gateway key <gateway_id>#<incarnation> of the gateway that holds the session, so the receiving worker accepts input from that gateway at once. They are 0 and empty for an entity no client owns.

The optional session section is written only when the owner's session is waiting to be reclaimed on the sender. session_orphan is 1 when the gateway reported that the client disconnected, 2 when the gateway's link to the sender dropped, and 3 when the session is over (a DespawnPlayer with end_now), in which case the receiver removes the pawn as soon as it holds it. session_reclaim_remaining is the number of seconds left of the SessionReclaimSeconds grace on the sender. It is a duration, not a timestamp, because the two workers' clocks differ. The receiving worker keeps the session waiting for that long, then removes the pawn unless the player has reconnected. For 2, a receiver that has a link to the same gateway treats the session as connected. A receiver treats a message that ends before the section as a connected session.

The optional extent section carries an extent the entity was given at runtime: extent_source is 0 (none), 1 (explicit box) or 2 (colliders), and the box is in the entity's local space. A sender that writes the section only to reach the update section writes extent_source 255 and zero vectors, and the receiver keeps its own copy's extent.

The optional update section carries the entity's UpdateInterval, in ticks (1 to 255). The sender writes it when the interval is above 1 or the dormancy section follows, and writes the sections before it as empty. A receiver that reads no update section sets the interval to 1.

The optional dormancy section says whether the entity is asleep: dormancy bit 0 means it is asleep and the receiver keeps it asleep, and bit 1 means it wakes when a gateway watches it. sleep_when_unobserved is the entity's SleepWhenUnobserved in seconds, 0 for never. The sender writes the section when either is set. A receiver that reads no dormancy section treats the entity as awake, with 0.

A writer includes each optional section when it, or any section after it, holds something. A reader stops at the end of the message and treats every section it did not read as empty.

The nested entity body's entity_epoch and new_epoch are the same in messages emitted by version 20. pending_inputs is empty for an entity without a predicted behavior. Otherwise it has this layout, where each game-defined input has no length prefix:

PendingInputs := u32 last_processed_input_tick,
                 u16 input_count,
                 PendingInput[input_count],
                 bool has_last_input,
                 [GameInput last_input when has_last_input is true],
                 [u16 state_length, raw state[state_length]],
                 [u32 last_event_tick, ServerEvents events]
PendingInput  := u32 tick, GameInput input

The stock predicted behavior sends at most 128 pending inputs. GameInput is the exact output of the game's INetworkInput.Serialize method. The optional trailing state is the output of the predicted behavior's WriteState: what Simulate carries from one tick to the next. The receiving worker applies it with ReadState, so the entity continues from the sender's simulation state. The optional trailing events are the server events the entity still holds, with last_event_tick, the newest tick the sender simulated: the receiver shows Simulate an event only for a tick after it, so an event applies once across the handover. A writer includes the events only when it holds some. A receiver MUST accept pending_inputs that end after last_input, or after state.

handover_state has this envelope:

HandoverState := u8 behavior_count, HandoverChunk[behavior_count]
HandoverChunk := u16 byte_length, raw[byte_length]

Chunks appear in behavior-index order. Their bytes are defined by each behavior's WriteHandoverState and ReadHandoverState pair. A receiver bounds each behavior to its chunk and skips chunks beyond its behavior count.

Interest management

A gateway tells each worker which parts of the world its clients need, instead of receiving every entity the mesh simulates. The unit is a region: one cell of a uniform grid over absolute world coordinates, identified by the same packing as a runtime container ID (three signed 21-bit fields, X in the high bits). Both ends derive the grid from shared configuration; the message carries it so a worker can detect a mismatch rather than filter with identifiers that mean something else.

InterestSubscribe :=
  u32 seq,
  u32 base_seq,
  f32 edge_x, f32 edge_y, f32 edge_z,
  f32 offset_x, f32 offset_y, f32 offset_z,
  bool planar,
  u8 flags,
  u16 add_count, u64 add_region[add_count],
  u16 remove_count, u64 remove_region[remove_count],
  u16 foci_count, u64 focus_region[foci_count],
  u16 entity_count, u64 net_id[entity_count],
  u32 set_count,
  u64 set_hash

InterestResync := u32 have_seq
EntityRedirect := u64 net_id, u16 new_worker_index
EntityForget   := u64 net_id, u32 entity_epoch
ClientFocusHint := u8 generation, u8 clear, [f64 x, f64 y, f64 z]   ; x, y, z are absent when clear is nonzero

A region's coordinate on each axis is floor((position - offset) / edge) in double precision, over absolute world coordinates rather than a peer's floating-origin frame. With planar set, the Y coordinate is always zero and regions are infinite columns.

A region identifier is that coordinate packed into three signed 21-bit fields, X in the high bits, XORed with the scope's salt. The salt is the SplitMix64 finalizer of the scope's 64-bit isolation ID, and is zero for the public world — so a mesh with no scopes puts exactly the packed coordinate on the wire, as it always did. Two scopes may hold the same absolute coordinates, because each scope has its own floating-origin frame; the salt is what keeps their regions, and therefore their subscriptions, disjoint. It is an XOR, so a peer that knows which scope an identifier belongs to — and both ends always do, since a subscription, a focus and an entity each sit in exactly one scope — recovers the coordinate by XORing again. add_region, remove_region and focus_region all carry salted identifiers.

flags bit 0 (Full) means the message replaces the worker's whole set; bit 1 (Commit) marks the last message of an update. A set too large for one message is split into several with the same seq, and a worker MUST NOT adopt any of them until the committing message verifies. Other bits are reserved.

set_count and set_hash describe the region set after the update: the count of regions, and the XOR over all of them of the SplitMix64 finalizer of the region identifier. The hash does not depend on order.

A worker applies an update when the grid matches its own, and either Full is set or base_seq equals the sequence it currently holds. On the committing message it checks set_count and set_hash; if either differs, or the base sequence did not match, it MUST keep the set it had, discard everything staged, and answer InterestResync with the sequence it holds. The gateway answers a resync with a Full snapshot. Repeating a message whose seq the worker already holds is a no-op when it describes the same set, which makes the protocol idempotent under retransmission. Gateways also send a Full snapshot on connection, on a worker incarnation change, and periodically as an audit.

focus_region is the full list of the regions this gateway's clients are focused on, and net_id the full list of entities it subscribes individually; both are sent on the committing message and replace whatever was there.

On a newly subscribed region the worker sends EntitySpawn for the entities bucketed in it. When an entity moves between regions it sends EntitySpawn to the gateways that subscribe the new region but not the old one, and EntityForget to the gateways that subscribe the old one but not the new one. Nothing is sent when a region is unsubscribed: the gateway already knows which entities that removes. A worker sends EntityRedirect for an individually subscribed entity that has moved to another worker, so the gateway links that worker instead.

ClientFocusHint tells the gateway where a client is looking, for example a sniper scope or a strategy camera. x, y, and z are absolute world coordinates in double precision, not a position in the sender's floating-origin frame: the client adds its current origin before sending, so an origin shift under the camera does not move the point the gateway thinks it is watching. x, y, and z are omitted entirely when clear is nonzero. It is advisory. A gateway MUST drop a hint containing a non-finite value, MUST rate-limit hints per client (InterestHintMaxHz, five per second by default), and MUST treat the point only as far as the server-owned FocusMode it has assigned that client allows: clamped to InterestHintMaxDistance of the client's pawn under the default PawnClamped mode, refused outright when the client has no pawn to clamp to, taken as sent under Free, or ignored under Disabled. A client cannot set or query its own FocusMode.

FarEntities := u16 count, FarEntry[count]
FarEntry    := u64 net_id, u32 entity_epoch, u8 kind,
               ; kind 0 (State) only:
               u8 flags, u16 prefab_id, u8 interest_group, f32 far_radius, f32 far_rate,
               [u64 owner_client_id]   ; flags bit 0
               [u64 instance_id]       ; flags bit 1
               f64 x, f64 y, f64 z, CompressedQuaternion rotation, f16 vx, f16 vy, f16 vz

FarEntities carries the far relevance tier: entities with a far radius, seen beyond their normal relevance radius as a pose and nothing else. kind 0 (State) is the entity's pose: x, y and z are absolute in the entity's scope, in double precision, converted out of every physics frame the entity stands in, and the rotation and velocity are in the same space. kind 1 (Gone) means the entity left the receiver's far tier; a Gone entry stops after kind. A worker sends an entity's entries, at its far rate, to each gateway with a focus of the entity's scope's own space (region identifiers salted with the scope only) within its far radius, and a Gone to a gateway that falls out of it. A worker that despawns a far entity sends EntityDespawn to those gateways too, which they treat as a Gone. A gateway relays each client the entries of the entities in that client's far tier: within the entity's far radius of one of its foci, not held as a replica, nearest first, and at most InterestFarMaxEntities. It MUST NOT send FarEntities to a client that negotiated a protocol below 26. A client ignores an entry for an entity it holds as a replica, and drops the far entity when that entity's EntitySpawn arrives.

A client withdraws its hint with a clear message, sent reliably under a generation one higher than its hints so far; interest returns to the pawn on the next evaluation. Hints and the clear travel on different channels and can cross, so the gateway drops any message whose generation is older than the newest it has seen (wrapping u8 comparison), and ignores a clear whose generation it already holds from a newer hint. The gateway adopts the generation of the first hint message on a connection. A clear is never rate-limited: narrowing interest is always allowed.

Worker-defined messages

WorkerMessage := u16 kind, raw_payload_to_end_of_message

The game assigns kind and defines the remaining bytes. SendToWorker selects either delivery class; reliable ordered is the default. Kind registration and the payload schema MUST match on both workers.

Receiver behavior

Peers send Hello before role-specific traffic. A worker ignores non-Hello messages until it has received one. A gateway likewise ignores client traffic until it has accepted that connection's Hello and marked the client welcomed.

Nebula readers throw on a truncated field. The client, gateway, and worker catch malformed-packet exceptions, log them, and continue polling. They log unknown or directionally unexpected message IDs. They do not require every message handler to consume all trailing bytes, so a compatible sender MUST still omit unspecified trailing data.

The gateway validates client ownership before forwarding ServerRpc, stamps session IDs, filters transform state by current worker ownership and epoch, and rejects older entity updates. A worker accepts input and ServerRpc for a session only from the gateway that holds it, and ignores player claims and despawns with an older generation. These checks limit accidental stale traffic and fence out a gateway that lost a session. Only the mesh peer credential authenticates a service peer, and only when a mesh token is configured.

On this page