09 — Full walkthrough: networked tic-tac-toe¶
This chapter assembles everything into the complete sample: a two-player, host-authoritative
tic-tac-toe with chat over the real LatticeRunner / LatticeSync nodes. Every file referenced here
exists in samples/godot-tictactoe/, and the netcode is verified headlessly by a C++ harness that
drives a full match over the native library — the run reports 60 checks, 0 failures, exit 0.
Verified against: samples/godot-tictactoe/README.md ("Latest result: 60 checks, 0 failures, exit
0"); the files listed below.
The architecture¶
The sample is split so the netcode is provable without the Godot editor: the deterministic rules and the node behaviour route through the binding's godot-cpp-independent glue, which the harness exercises with the real core.
flowchart TD
subgraph GODOT["Godot project — needs the editor + GDExtension binaries"]
M["main.gd<br/>lobby, 3×3 grid, chat panel, redraw"]
B["board.gd<br/>replicated board: schema, authoritative moves, place RPC"]
C["chat.gd<br/>chat over send_event / the runner event signal"]
RU["ttt_rules.gd<br/>pure win/draw/legal-move logic"]
end
subgraph PROOF["Headless proof — runs without the editor"]
H["harness/ttt_harness.cpp + shared/tictactoe_rules.h<br/>host + client over the local server; asserts the match"]
GL["binding glue (lattice_glue.cpp) + real liblattice"]
end
M --> B
M --> C
B --> RU
H --> GL
Verified against: samples/godot-tictactoe/README.md (project layout);
samples/godot-tictactoe/godot/scripts/*.gd, samples/godot-tictactoe/harness/ttt_harness.cpp.
| File | Role | Runs headlessly? |
|---|---|---|
godot/scripts/ttt_rules.gd |
Pure rules: legal move, win/draw (GDScript mirror of the C++ rules) | via its C++ twin |
godot/scripts/board.gd |
Replicated board: register_fields, authoritative _apply_move, place RPC |
via the glue |
godot/scripts/chat.gd |
Chat over send_event + the runner event signal |
via the glue |
godot/scripts/main.gd |
Lobby, grid, chat UI, redraw | ❌ needs the editor |
harness/ttt_harness.cpp + shared/tictactoe_rules.h |
Runnable netcode proof | ✅ |
The node trees¶
Two scenes. The main scene holds the game controller, a LatticeRunner, and the UI; the board scene is
the spawnable replicated object.
Main (main.gd)
├── LatticeRunner (native)
└── UI (CanvasLayer)
└── VBox
├── Status, Turn (Label)
├── Lobby → HostButton, JoinButton
├── Grid → Cell0 … Cell8 (Button)
└── Chat → ChatLog (RichTextLabel), ChatInput (LineEdit)
Board (board.gd)
└── LatticeSync (native)
Verified against: samples/godot-tictactoe/godot/scenes/main.tscn:14-93,
samples/godot-tictactoe/godot/scenes/board.tscn:12-15, project.godot:13
(run/main_scene="res://scenes/main.tscn").
The full flow, end to end¶
sequenceDiagram
participant H as Host (server + X)
participant C as Client (O)
H->>H: start_game(HOST) → spawn(board.tscn, local_player)
C->>C: start_game(CLIENT)
H-->>C: board replicates (spawned → auto-instantiate → bind)
C-->>H: send_rpc("place", [cell], STATE_AUTHORITY) [client requests]
H->>H: place() → _apply_move → validate → write cells (auto-replicate)
H-->>C: dirty fields replicate (state_updated → redraw)
C-->>H: send_event(EVT_CHAT, [name,text], STATE_AUTHORITY) → host relays ALL_PEERS
H->>H: check_winner → winner replicates to C
Verified against: main.gd, board.gd, chat.gd (the handlers) and
harness/ttt_harness.cpp (the asserted sequence).
1. Host starts and spawns; client joins¶
await runner.start_game(LatticeRunner.MODE_HOST, "ttt-room")
runner.listen(PORT) # accept player O
runner.spawn(BoardScene, runner.get_local_player())
board = get_tree().get_first_node_in_group("ttt_board")
await runner.start_game(LatticeRunner.MODE_CLIENT, "ttt-room")
runner.connect_to("127.0.0.1", PORT)
# board arrives via replication; _on_spawned binds it
Verified against: main.gd:68-75,85-86. The listen / connect_to pair is what establishes the
host↔client link (chapter 02); it calls the same
lattice_runner_listen / lattice_runner_connect the headless harness drives directly.
2. The board replicates¶
The board declares its 11-field schema in _ready (runs on every instance), so host and client agree on
the type. The host's spawn replicates to the client, whose spawned signal fires; the runner
auto-instantiates board.tscn and binds its LatticeSync (chapter 05).
Verified against: board.gd:44-51; lattice_runner.cpp:515-538; main.gd:98-105.
3. Moves: request → validate → replicate¶
The client sends the "place" RPC; the host validates against TttRules and only then writes the cell,
which replicates automatically. Illegal moves change nothing (chapter 08).
Verified against: board.gd:74-101.
4. Chat both ways¶
Chat rides send_event; the host re-broadcasts a client's line to ALL_PEERS and re-stamps the
authenticated peer id (chapter 07).
Verified against: chat.gd:38-73.
5. Win / draw detection¶
After each applied move the host runs TttRules.check_winner; the winner field replicates to the
client, and main.gd::_redraw shows "X wins!" / "O wins!" / "Draw."
Verified against: board.gd:96-99, ttt_rules.gd:38-46, main.gd:130-138.
Run the proof yourself¶
Because the netcode is engine-free (it routes through the binding glue + the real core), you can run the whole match headlessly, no Godot required:
cd samples/godot-tictactoe
bash harness/build.sh # native (g++) build + run, loopback transport
LATTICE_TRANSPORT=udp ./harness/out/ttt_harness # the identical match over real localhost UDP
Expected result (this environment): 60 checks, 0 failures, exit 0 on native Linux (g++ 11) and Windows cross (mingw + wine), including over UDP.
Verified against: samples/godot-tictactoe/README.md ("Run it", "Latest result"),
samples/godot-tictactoe/harness/build.sh:15-16.
Play it in the editor (once the binaries are built)¶
The scenes and GDScript need Godot 4.2+ and the built GDExtension:
- Build the extension + copy it and the core into
godot/addons/lattice/bin/(see chapter 01 and that folder's README). - Open
godot/in Godot 4.2+ and runscenes/main.tscn. - Launch a second instance (or an export): one clicks Host (player X), the other Join
(player O). From the command line:
godot --path godot -- --hostandgodot --path godot -- --join(the sample reads these fromOS.get_cmdline_user_args()). - Play: click cells on your turn; type in the chat box and press Enter.
Verified against: samples/godot-tictactoe/README.md ("Running the game"); main.gd:57-60
(the --host / --join command-line handling).
The editor path is authored, not run here
The rendering and lifecycle in main.gd/board.gd/chat.gd require the Godot editor + godot-cpp,
which are not in the reference environment — so they're authored but not executed here. Everything
they call (the schema, RPC, event, and validation logic) is what the harness verifies.
Verified against: samples/godot-tictactoe/README.md ("What needs the Godot editor + godot-cpp").
The C# (Godot .NET) option¶
The same native LatticeRunner / LatticeSync nodes are scriptable from C# on the Godot Mono/.NET
build, via the Variant Call/Connect API — no extra build step beyond the .NET project:
_runner = GetNode("LatticeRunner");
_runner.Connect("player_joined", new Callable(this, nameof(OnPlayerJoined)));
_runner.Connect("spawned", new Callable(this, nameof(OnSpawned)));
_runner.Call("start_game", 1 /* MODE_HOST */, "room-1");
long local = (long)_runner.Call("get_local_player"); // -1 == LOCAL_PLAYER_UNKNOWN
if (local >= 0)
_runner.Call("spawn", PlayerScene, local);
Verified against: bindings/lattice-godot/demo/Game.cs:15-29.
Recap: what's verified vs. what you wire¶
| Capability | Status in this tutorial |
|---|---|
Runner host/client mode, _physics_process tick |
✅ verified (glue + harness) |
Type schema (register_fields / replicate), spawn, replicated state |
✅ verified |
RPCs (send_rpc → method call) |
✅ verified (the sample's place move) |
Custom events (send_event / the event signal) |
✅ verified (chat, byte-exact both ways) |
| Host-authoritative validation + rejection, win/draw | ✅ verified |
| Auth login → access token | ✅ real endpoints (/login, /guest), called from Godot with HTTPRequest |
Host↔client transport from GDScript (listen / connect_to) |
✅ verified — bound methods calling lattice_runner_listen / lattice_runner_connect; two runners reach CONNECTED in the compile-check (ch. 02) |
| Matchmake → resolve → connect | ✅ wired by the shipped LatticeMatchmaker helper — you still supply a registered game server (ch. 04) |
Shared-authority transfer (request_authority / authority_changed) |
⚠️ real ABI wrappers, not exercised by the sample (ch. 08) |
Input/prediction (get_input) |
⚠️ get_input() returns an empty dict — production input channel is a catch-up (ch. 08) |
| Node layer build, scenes, GDScript in the editor | 📝 need godot-cpp + Godot 4.2+ (documented, not run here) |
That honesty is deliberate: build on the verified paths first, and treat the ⚠️/📝 rows as integration work you own or catch-up items on the binding's roadmap.
Where to go next¶
- Serialize richer state with compressed floats, vectors, and quaternions — see the
T_*field kinds andoptsin chapter 05. - Add the in-game network debug overlay (
res://addons/lattice/lattice_net_debug.tscn, toggle F3), which readsrunner.get_net_stats()— seebindings/lattice-godot/README.md("In-game network debug overlay"). - Browse the API reference for the full C ABI behind the binding.