Skip to content

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.tscn (run this scene)
Main                       (main.gd)
├── LatticeRunner          (native)
└── UI  (CanvasLayer)
    └── VBox
        ├── Status, Turn   (Label)
        ├── Lobby          → HostButton, JoinButton
        ├── Grid           → Cell0 … Cell8  (Button)
        └── Chat           → ChatLog (RichTextLabel), ChatInput (LineEdit)
board.tscn (spawned by the host, auto-instantiated on the client)
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

main.gd — host
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")
main.gd — client
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:

  1. Build the extension + copy it and the core into godot/addons/lattice/bin/ (see chapter 01 and that folder's README).
  2. Open godot/ in Godot 4.2+ and run scenes/main.tscn.
  3. Launch a second instance (or an export): one clicks Host (player X), the other Join (player O). From the command line: godot --path godot -- --host and godot --path godot -- --join (the sample reads these from OS.get_cmdline_user_args()).
  4. 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:

demo/Game.cs — the Variant-call pattern
_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 and opts in chapter 05.
  • Add the in-game network debug overlay (res://addons/lattice/lattice_net_debug.tscn, toggle F3), which reads runner.get_net_stats() — see bindings/lattice-godot/README.md ("In-game network debug overlay").
  • Browse the API reference for the full C ABI behind the binding.