State sync keeps live values — positions, health, game phase — on the server, so every member (including late joiners) always sees the current truth. For player movement, the SpawnSync and PlayerSpawner nodes do it with zero networking code.

The model: room state and entities

A room's live state has two levels:

Room state Entities
Shape One JSON object per room Many objects, each with an entity_id and an owner
Who may write The host only The entity's owner only (its creator)
Typical use Game phase, scores, timer, settings Player transforms, bombs, pickups, projectiles
Cleanup Lives as long as the room Deleted by the owner, or garbage-collected when the owner leaves

Writes are shallow patches: keys you provide overwrite, keys you omit stay, and a null value removes the key. Changes are broadcast to all members — including the writer, whose copy doubles as the acknowledgment.

Player movement, zero code: PlayerSpawner + SpawnSync

  1. Add a PlayerSpawner node to your level and assign Player Scene in the Inspector.
  2. Inside that player scene, add a SpawnSync node as a child of the root.

Done. When a room is entered, PlayerSpawner instantiates one copy of the scene per player, wires each SpawnSync (entity id = player id, is_local on your own copy), despawns on leave, and survives reconnects. The local copy sends its transform only when it changes; remote copies interpolate smoothly.

PlayerSpawner exports

Export Default Meaning
player_scene The scene instantiated per player (required)
spawn_root empty Where instances are added; empty = the spawner's parent
spawn_points empty A node whose children (Marker2D/3D, Node2D/3D) are used as spawn positions, round-robin by roster order; empty = the root's origin
spawn_local_player true Also spawn an avatar for you; turn off if you place your own player by hand

Signals: player_spawned(player, node) and player_despawned(player_id, node).

Optional hook on your player scene's root script — called right before add_child:

func setup(player: SWPlayer, is_local: bool) -> void:
    name_label.text = player.display_name()
    if not is_local:
        $Camera2D.enabled = false    # only your own avatar gets the camera

PlayerSpawner reconciles rather than blindly spawns: on every room entry (first join, rejoin, and automatic resume after a reconnect) it despawns avatars whose players are gone and spawns any that are missing — idempotent by design.

SpawnSync exports

Add SpawnSync as a child of the node to replicate (Node2D or Node3D):

Export Default Meaning
entity_id empty Stable entity id. Empty on a local node = your own player id (one avatar per player). Other owned objects need a unique id you set.
is_local false true on the single copy this client owns (it sends, never interpolates); false on remote copies (they interpolate, never send)
sync_position true Replicate position (x,y in 2D; x,y,z in 3D)
sync_rotation true Replicate rotation (rot in 2D; rx,ry,rz in 3D)
sync_scale false Replicate scale
synced_properties [] Extra parent property names to replicate (e.g. "hp", "team"). Primitives only — for a Color or Vector2, sync the components
send_rate 8 Updates per second the local copy may send (1–10). The default stays inside the server's 10/s state budget with headroom
interpolate true Smooth remote copies toward incoming state instead of snapping
interpolation_speed 16.0 Higher = snappier follow, lower = smoother/laggier (1–40, frame-rate independent)
free_parent_on_remove true queue_free() the parent when the entity is removed on the server

Behavior worth knowing:

  • Dirty-check: the local copy compares the state it would send with the last one sent — idle objects cost zero bandwidth.
  • Reconnect-safe: on every room_joined (including resumes) it re-registers a local entity, or re-applies the room snapshot on a remote one.
  • Cleanup: a local SpawnSync deletes its entity when it leaves the tree, so other players see the object disappear.
  • set_local(true) switches a copy to sending at runtime (used by PlayerSpawner).

Manual entity sync (non-player objects)

Use the entity API directly for owned objects that aren't node transforms — or when you want full control:

# The player who plants the bomb owns it:
await SpawnWeaver.set_entity("bomb_%d" % bomb_index, {"x": 10, "y": 4, "fuse": 3.0})

# Later — patch only what changed; null removes a key:
await SpawnWeaver.patch_entity("bomb_1", {"fuse": 1.5})
await SpawnWeaver.patch_entity("bomb_1", {"defused_by": null})

# Gone:
await SpawnWeaver.delete_entity("bomb_1")

Everyone (including you) receives the change:

SpawnWeaver.entity_changed.connect(func(id, state, patch, owner_id):
    if id.begins_with("bomb_"):
        update_bomb_visual(id, state))

SpawnWeaver.entity_removed.connect(func(id, reason):
    # reason: "deleted" (owner deleted it) or "owner-left" (owner's membership ended)
    if id.begins_with("bomb_"):
        remove_bomb_visual(id))

Only the creator of an entity may set/patch/delete it — anyone else gets state-forbidden. When an owner's membership ends (left, or grace expired), their entities are garbage-collected with reason owner-left.

Room state (host only)

One shared object for room-wide facts. Only the host writes it; everyone reads it.

# Host:
if SpawnWeaver.is_host:
    await SpawnWeaver.set_room_state({"phase": "combat", "round": 2})

# Everyone (including the host — the broadcast doubles as the ack):
SpawnWeaver.room_state_changed.connect(func(state, patch):
    if patch.has("phase"):
        switch_phase(state["phase"]))

set_room_state() is also a shallow patch with the same null-removes-key rule. Non-hosts get not-host. Remember host migration: after host_changed, the new host takes over writing.

Late joiners: the snapshot

Late joiners need no special handling — the full state snapshot arrives inside the room payload of room_joined (and after matchmaking or a reconnect resume):

SpawnWeaver.room_joined.connect(func(room):
    apply_phase(room.state.get("phase", "lobby"))       # room-level state
    for entity_id in room.entities:                      # entity_id -> {owner_id, state}
        var entry: Dictionary = room.entities[entity_id]
        spawn_visual(entity_id, entry["owner_id"], entry["state"]))

SpawnWeaver.room.state and SpawnWeaver.room.entities stay current from then on.

Limits

Limit Value Error when exceeded
Entities per room 50 state-limit-exceeded
State per entity 4 KB state-too-large
Room state size 16 KB state-too-large
State updates per client 10/s sustained, burst 20 rate-limited (retryable)

See Limits for the full table.

Common mistakes

Mistake What happens Fix
Patching an entity you don't own state-forbidden Only the creator writes an entity; route the change through its owner or an event
Writing room state as a non-host not-host Check SpawnWeaver.is_host; hand over on host_changed
Sending state every frame rate-limited Send at 8–10/s max; SpawnSync's default send_rate is safe
Two clients using the same entity_id The second writer gets state-forbidden Ids must be unique per owner-created object; player ids are already unique
Storing blobs (inventories, chat logs) in state state-too-large State is for small live values; use storage or events
Expecting set_entity to merge Keys you omit are gone set_entity replaces; patch_entity merges
Vector2/Color in synced_properties Property doesn't sync correctly Primitives only — sync x/y/components as separate properties

Next

  • Events — transient messages, and when to prefer them
  • Best practices — events vs. entities vs. room state vs. storage