Skip to content

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:

  1. declare which fields replicate on the LatticeSync (register_fields or replicate),
  2. spawn an instance on the authority, and
  3. read replicated changes on every peer via the runner's spawned signal and the sync's state_updated signal.

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:

board.tscn node tree
Board            (board.gd)
└── LatticeSync  (native LatticeSync node)

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:

board.gd — the board's replicated schema
@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 parentLatticeSync 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:

demo/player.gd — replicate + a per-field change signal
@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_INTINT32 4 bytes, T_LONGINT64 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
demo/player_state.gd — an explicit schema with opts
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):

Host spawns the board — from main.gd
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:

Client binds the board when it replicates in — from main.gd
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:

board.gd
sync.state_updated.connect(func(): board_changed.emit())

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.