05 — Networked objects¶
A networked object is a piece of replicated state the authority owns and every peer sees. In Godot
it's a spawnable scene whose root has a LatticeSync child. You:
- declare which fields replicate on the
LatticeSync(register_fieldsorreplicate), spawnan instance on the authority, and- read replicated changes on every peer via the runner's
spawnedsignal and the sync'sstate_updatedsignal.
The tic-tac-toe sample has exactly one networked object — the board — so it's a clean, complete example.
Verified against: samples/godot-tictactoe/godot/scripts/board.gd,
samples/godot-tictactoe/godot/scenes/board.tscn,
bindings/lattice-godot/src/lattice_sync.cpp, bindings/lattice-godot/src/lattice_sync.h.
The scene shape¶
A spawnable object is a scene whose root carries your gameplay script and has a LatticeSync
child. The board scene is exactly that:
Verified against: samples/godot-tictactoe/godot/scenes/board.tscn:12-15.
The scene's res:// path is its stable catalog key — the same key on every peer, so type ids agree.
The binding uses the parent scene's scene_file_path as that key.
Verified against: lattice_sync.cpp:98-106 (_catalog_key = parent's get_scene_file_path());
samples/godot-tictactoe/godot/scenes/board.tscn:2-5 ("res://scenes/board.tscn" is the catalog key).
How replication works: exported props ↔ a POD block¶
LatticeSync mirrors its parent's exported properties into and out of the core's flat POD state
block every tick. You write an exported property in GDScript; the sync copies it into the block and
marks the field dirty (on the authority), and copies replicated values back out (on every peer). There
is no manual mark_dirty — dirty tracking is automatic.
Verified against: lattice_sync.cpp:217-256 (_push_state_to_core, which also calls
lattice_object_mark_dirty per field), lattice_sync.cpp:258-297 (_pull_state_from_core);
board.gd:9-11 ("Dirty tracking is AUTOMATIC").
Declaring the fields¶
There are two ways to declare which fields replicate. Both build the same flat schema under the hood.
register_fields — an explicit list (what the board uses)¶
Pass an array of [name, T_*] (or [name, T_*, {opts}]) entries. The board declares nine int cells
plus turn and winner, in a fixed order:
@onready var sync: LatticeSync = $LatticeSync
@export var cell0: int = TttRules.EMPTY
# … cell1 … cell8 …
@export var turn: int = TttRules.MARK_X
@export var winner: int = TttRules.NONE
func _ready() -> void:
add_to_group("ttt_board")
sync.register_fields([
["cell0", LatticeSync.T_INT], ["cell1", LatticeSync.T_INT], ["cell2", LatticeSync.T_INT],
["cell3", LatticeSync.T_INT], ["cell4", LatticeSync.T_INT], ["cell5", LatticeSync.T_INT],
["cell6", LatticeSync.T_INT], ["cell7", LatticeSync.T_INT], ["cell8", LatticeSync.T_INT],
["turn", LatticeSync.T_INT],
["winner", LatticeSync.T_INT],
])
sync.state_updated.connect(func(): board_changed.emit())
Verified against: board.gd:20-53.
Each name must be an exported property on the parent — LatticeSync reads/writes it by name every
tick.
replicate — tag properties one at a time (with inferred types)¶
For a player-like object, tag each exported property; the field kind is inferred from the property's
current value type unless you override it in opts:
@export var health: float = 100.0
@export var net_position: Vector3
func _ready() -> void:
sync.replicate("health")
sync.replicate("net_position", LatticeSync.INTERPOLATED)
sync.connect("health_changed", _on_health_changed) # per-field change signal
Verified against: bindings/lattice-godot/demo/player.gd:8-21;
lattice_sync.cpp:108-124 (infer_kind_) for the type inference;
lattice_sync.cpp:152-156 (replicate) for the call it drives.
The field-type enum¶
LatticeSync.T_* names the leaf kinds:
T_BOOL, T_INT (32-bit), T_LONG (64-bit), T_FLOAT, T_DOUBLE, T_VECTOR2, T_VECTOR3,
T_VECTOR4, T_QUAT, T_STRING, T_BYTES.
Verified against: lattice_sync.h:57-69 (the FieldType enum), lattice_sync.cpp:83-93 (bound as
constants).
GDScript int is 64-bit — pick T_INT vs T_LONG deliberately
A GDScript int is always 64-bit. T_INT stores it as a 4-byte int32 in the block (values are
truncated to 32 bits); T_LONG stores the full 8-byte int64. The board uses T_INT because its
values are 0–2. Use T_LONG for anything that can exceed 32 bits (xp, large scores).
Verified against: lattice_sync.cpp:114 ("GDScript int is 64-bit"),
lattice_sync.cpp:232-233 (T_INT→INT32 4 bytes, T_LONG→INT64 8 bytes).
Quantization and capacity options¶
The optional third opts dictionary adds per-kind detail:
| Opt | For | Effect |
|---|---|---|
{"range":[lo,hi]} |
T_FLOAT |
Promotes to a compressed (quantized) float over the range |
{"bounds":[lo,hi]} |
T_VECTOR2/3/4 |
Quantization bounds per component |
{"precision":p} |
floats / vectors | Quantization step |
{"quat_bits":n} |
T_QUAT |
Smallest-three quaternion bit budget |
{"capacity":n} |
T_STRING / T_BYTES |
Fixed byte capacity (defaults: 64 / 256) |
{"type":LatticeSync.T_*} |
any | Override the inferred kind |
sync.register_fields([
["is_alive", LatticeSync.T_BOOL],
["score", LatticeSync.T_INT],
["xp", LatticeSync.T_LONG],
["stamina", LatticeSync.T_FLOAT, {"range": [0.0, 100.0], "precision": 0.1}],
["position", LatticeSync.T_VECTOR3, {"bounds": [-4096, 4096], "precision": 0.001}],
["velocity", LatticeSync.T_VECTOR3, {"bounds": [-256, 256], "precision": 0.01}],
])
Verified against: bindings/lattice-godot/demo/player_state.gd:14-22;
lattice_sync.cpp:126-150 (add_field_ reads range/bounds/precision/quat_bits/capacity and
applies the string/bytes capacity defaults).
INTERPOLATED / PREDICTED flags are consumed by the core, not the node
replicate("x", LatticeSync.INTERPOLATED) and LatticeSync.PREDICTED set replication flags that
the core's snapshot path consumes; the node layer records the field and passes the flag through — it
does not itself interpolate.
Verified against: lattice_sync.cpp:152-153 ((void)flags; // INTERPOLATED/PREDICTED are
consumed by the core's snapshot path), lattice_sync.h:37-41.
Spawning (authority only)¶
Only an authority (MODE_SERVER / MODE_HOST / MODE_SHARED_HOST) may spawn. spawn(scene, owner)
instantiates the scene, registers its type from the LatticeSync schema, pushes the initial state, and
returns the new netid (0 on error):
await runner.start_game(LatticeRunner.MODE_HOST, "ttt-room")
runner.spawn(BoardScene, runner.get_local_player()) # owned by the host's local peer
board = get_tree().get_first_node_in_group("ttt_board") # board.gd added itself to this group
Verified against: main.gd:68-76 (get_tree); lattice_runner.cpp:406-447 (spawn: authority check,
instantiate, resolve type, lattice_spawn, bind + push state).
A non-authority spawn is ignored with a warning and returns 0:
if (!is_server()) {
UtilityFunctions::push_warning("LatticeRunner.spawn ignored: not authority");
return 0;
}
Verified against: lattice_runner.cpp:407-410.
Reacting to replication¶
Every peer learns about objects through the runner's spawned signal and each object's
state_updated signal.
spawned — an object appeared (including remote auto-instantiation)¶
When the authority spawns an object, remote peers receive it: the runner auto-instantiates the
catalog scene for that type and binds its LatticeSync, then emits spawned(netid, type, owner). In
the sample the client waits for this to bind the replicated board:
func _on_spawned(_netid: int, _type: int, _owner: int) -> void:
if not runner.is_server():
board = get_tree().get_first_node_in_group("ttt_board")
_bind_board()
if chat == null:
_setup_chat("O")
Verified against: main.gd:94-101; lattice_runner.cpp:515-538 (_on_spawned auto-instantiates from
the catalog for a remote spawn, binds the child LatticeSync, then emits spawned).
state_updated — a replicated field changed¶
state_updated fires on the object's LatticeSync when the core applies replicated changes to it. The
board redraws on it:
Verified against: board.gd:53; lattice_sync.cpp:295 (emit_signal("state_updated") at the end of
_pull_state_from_core).
Per-field change signals: <property>_changed
Besides state_updated, LatticeSync emits a signal named "<property>_changed" for each
replicated field when it applies an update — e.g. health_changed. Connect to just the field you
care about instead of re-reading the whole block.
Verified against: lattice_sync.cpp:293 (emit_signal(String(field_props_[i]) +
"_changed")), demo/player.gd:21 (sync.connect("health_changed", …)).
Despawning¶
Authority-only. despawn(netid) or despawn_node(node) removes it; every peer sees
despawned(netid) and the auto-instantiated node is freed.
Verified against: lattice_runner.cpp:449-462 (despawn / despawn_node),
lattice_runner.cpp:540-544 (_on_despawned frees the node + emits despawned).
Register the same fields, in the same order, on every peer
Type ids and content hashes come from the field schema. If host and client declare the board's
fields in a different order, replication breaks. The sample declares the identical 11-field list on
both peers (in board.gd::_ready, which runs on every instance of the scene), and its order matches
the headless harness's make_board_schema() so both register the same content hash.
Verified against: board.gd:44-51 ("SAME order as the harness's make_board_schema()"),
samples/godot-tictactoe/README.md ("both register the same content hash").
Next: 06 — RPCs.