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
- Add a
PlayerSpawnernode to your level and assign Player Scene in the Inspector. - Inside that player scene, add a
SpawnSyncnode 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
SpawnSyncdeletes 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 byPlayerSpawner).
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