Skip to content

Case study: shipping Battleship on Lattice

A walkthrough of how a real, complete, two-player game was built on the Lattice backbone — written for someone about to do the same thing with their own game.

The subject is samples/battleship3d: 3D Battleship with in-game chat, native C++ and raylib, Linux and Windows, playing against the live control plane and relay. It is small enough to read end to end and complete enough to have hit every sharp edge.

This page is deliberately not a feature tour. It is the set of decisions that were forced by how the platform actually behaves, each with the evidence that forced it — because those are the decisions you will have to make too, and the ones that are expensive to get wrong late.

The five-minute version

  • Stamp a room id into every payload and drop mismatches. The relay is one shared world.
  • Do not trust the reliable flag on lattice_send_event. Build your own ack/resend.
  • Do not derive turn order from peer ids, or from who created the room.
  • Budget payloads against a hard 1024-byte clamp with no fragmentation.
  • Keep secrets client-side and prove honesty afterwards with commit-reveal.

1. Getting into a session

Three HTTP calls and one UDP connect. There is no separate room object to create: /matchmake makes the session and mints the six-character room code players share.

sequenceDiagram
  participant H as Host
  participant A as auth
  participant D as director
  participant G as game server
  participant J as Joiner
  H->>A: POST /guest {display_name}
  A-->>H: access_token
  H->>D: POST /matchmake {region, mode, max_players} + api key
  D-->>H: room_code + session_handle + endpoint
  H->>D: POST /resolve {session_handle}
  D-->>H: session_token
  Note over H,J: host reads out the 6-character code
  J->>A: POST /guest
  J->>D: POST /resolve {room_code} + api key
  D-->>J: endpoint + session_token
  H->>G: connect(endpoint, session_token)
  J->>G: connect(endpoint, session_token)

The non-obvious step is that the host must call /resolve on its own handle too. /matchmake hands back the code and handle but no session_token, and the token is what connect() carries. Miss it and the host has a room it cannot join.

Verified against: samples/battleship3d/src/net/backend.cpp:80-122 (create_room), samples/battleship3d/src/net/backend.cpp:124-137 (join_room), samples/battleship3d/src/net/backend.cpp:153-206 (resolve_request), samples/battleship3d/src/net/backend.cpp:57-78 (guest_login).

Short codes are scoped to your game, so send the api key

Six characters is not enough to be unique across every game on a platform, so codes are unique within one game and /resolve needs X-Lattice-Api-Key to know whose namespace to search. Send the key on /matchmake too — that is what tags the session with your game in the first place. The key is public by design: it names the game and authorises nothing, while the player's bearer token is what authorises. Another game's key does not open your room; it answers 404, exactly as an unknown code does.

Two consequences worth designing for. Codes expire — six hours after the session was created the code stops resolving, while the opaque handle keeps working, so a code written on a sticky note does not stay a key forever. And matchmaking is scoped by game as well: two games that both call a mode casual get separate session pools instead of each other's players.

Verified against: samples/battleship3d/src/net/backend.cpp:37-41 (auth_headers), samples/battleship3d/src/net/backend.cpp:146-151 (resolve_by_code).

Normalise what the player typed, do not just validate it

A code that gets read out over voice chat comes back with the wrong case, stray spaces or dashes, and the occasional O typed for a 0. All of those are the right code, and refusing them is a bug the player experiences as "this game is broken".

The alphabet is picked to make that tractable. Remove the five vowels and a code cannot spell a word — worth more than a blocklist, because six random letters will eventually produce something a player screenshots. Then of each look-alike pair keep the clearer glyph and map the other onto it on input:

O -> 0        I, 1 -> L        2 -> Z        5 -> S        8 -> B

What survives is 034679 plus the 21 consonants: 27 characters, 387 million codes. The client normalises before it validates, and shows the corrected form next to the field so a fixed typo is visible rather than surprising.

Both ends must agree, exactly

The client's normaliser and the director's are separate implementations of one contract. If they ever drift, valid codes start being refused in the client before they are ever sent — which looks like a broken server from inside the game. The demo pins every mapping in its unit suite for that reason, and asserts that every character the generator can emit round-trips.

Verified against: samples/battleship3d/src/core/protocol.cpp:82-105 (normalize_room_code), samples/battleship3d/src/core/protocol.cpp:107-112 (room_code_valid), samples/battleship3d/src/core/protocol.cpp:74 (kRoomCodeAlphabet).

/matchmake is find-or-create, not create

This one changes your UI's meaning, so it is worth stating plainly. Three independent players calling /matchmake with the same mode and region:

AliceRoom -> S76G4P  player_count 1 / 2
BobRoom   -> S76G4P  player_count 2 / 2   <-- joined Alice's room
CarolRoom -> 0NHBZ4  player_count 1 / 2   <-- Alice's was full

Bob never saw Alice's code. He was matchmade into her session, which is exactly what a matchmaker should do — but it means a "Create Game" button is really a "find or open a room" button, and both players can legitimately believe they are the host.

A per-room unique mode string would sidestep the coalescing, but only registered modes resolve to a server:

mode='battleship-abc123xyz' -> {"error":"no suitable instance with capacity"}
mode='battleship'           -> {"room_code":"0NHBZ4", "session_handle":"VEDU27...", ...}

So plan for two players who both think they are the host. Section 4 shows the pattern to use.

A stranger can land in your room before your friend does

This is the consequence worth designing around, not just noting. While your host sits waiting on a shared room code, any other player who matchmakes the same mode and region can be placed into that session — they never see your code, and your friend arrives to find the room full.

Most of this is now closed at the server, by a fix this demo prompted. /resolve used to be a read-only lookup: it handed out a session token without recording that anyone had arrived, so a room two friends were already playing in still looked half-empty to the matchmaker and kept attracting strangers. Resolving now takes a seat, so a two-player room fills and drops out of the matchmaking pool. Admission is idempotent, which it has to be — the host resolves its own handle immediately after matchmaking, and would otherwise fill its own room.

Recording arrivals immediately exposed a second problem, though, and it is the more instructive one: nothing ever removes a session or releases a departed player's seat. So a host who quit left a half-occupied room behind, the next person to press "Create Game" was seated into it and filled it, and that host's friend was refused as "room full" by a player who had long since gone. Quit-and-retry is the most common thing anyone does, so this was worse than the bug it replaced. The fix is a matchmaking window: a session is only offered for automatic pairing while it is fresh (five minutes). Joining by code or handle ignores the window entirely — that is an invitation, not a guess, and codes have their own six-hour lifetime.

The general lesson: adding occupancy accounting to a system that has no departure signal turns "too loose" into "too sticky". If you record arrivals, bound how long that record is allowed to influence anything.

What remains is the genuine race: two strangers who both press "Create Game" within the same few minutes, before either has a friend, still land together. Closing that needs the director to offer explicitly private sessions — a flag on /matchmake that keeps a session out of the pool until its creator opens it. Until then:

  • Play it as a feature. Accept whoever arrives and let the tiebreak in section 4 settle turn order. This is what the demo does, and it makes the game work as a quick-match.
  • Check who you got. Names are exchanged in HELLO, so a host expecting a specific friend can notice a stranger and leave. Cheap, and honest about what it can and cannot enforce.
  • Add a shared secret. Require a passphrase in HELLO (or bind the room stamp to code + passphrase) so an uninvited peer's traffic is dropped. This closes it for honest clients but is not a security boundary — see section 2.

Connecting

Pass the session token to lattice_runner_connect and start ticking. Both players are LATTICE_MODE_CLIENT — the live server runs no game logic, so there is no host runner.

LatticeSession.start
lattice_runner_config cfg;
std::memset(&cfg, 0, sizeof(cfg));
cfg.tick_rate_hz = 60;
runner_ = lattice_runner_create(&cfg);
lattice_runner_set_callbacks(runner_, &cb);
lattice_runner_start(runner_, LATTICE_MODE_CLIENT);
lattice_runner_connect(runner_, ip.c_str(), port, tok, tok_len);

Verified against: samples/battleship3d/src/net/session.cpp:70-140 (start).

Resolve hostnames yourself

The director returns endpoints as hostnames (game.wdit4me.co.uk, port 47900), but the core parses addresses with inet_pton only — no DNS. Handing a hostname to lattice_runner_connect fails without a packet leaving the machine. Resolve to a dotted quad first.

Verified against: reference/src/platform/socket.h:74-80 (from_ipv4), samples/battleship3d/src/net/resolve.cpp:54-100 (resolve_ipv4).

A trap in the dial timeout

Connecting is asynchronous — you start it, then watch for on_connected while a timeout runs. That shape has an unsigned-arithmetic trap in it that cost real debugging time here, so it is worth thirty seconds of your attention.

The frame loop looked like this:

uint64_t now = now_ms();          // sampled at the TOP of the frame
poll_connect_worker();            // ...but THIS is what stamps connect_started_ms_
...
if (now - connect_started_ms_ > kDialTimeoutMs) { /* give up */ }

On the one frame where the worker finishes, connect_started_ms_ is stamped after now was read, so it is larger. Both are uint64_t, so the subtraction wraps to something near 2⁶⁴, the comparison is trivially true, and the dial is abandoned on its very first tick.

The symptom is maximally misleading: every connection fails instantly with "the server did not answer", which reads exactly like a dead server or a blocked port. The transport was perfectly healthy the entire time. It was only found by printing the state each frame rather than re-reading the logic.

Two habits avoid it: read the clock after anything that might stamp a start time, and compare timestamps in a way that cannot wrap.

uint64_t now = now_ms();          // AFTER the worker pump
...
if (now > connect_started_ms_ && now - connect_started_ms_ > kDialTimeoutMs) { /* give up */ }

Verified against: samples/battleship3d/src/app/app.cpp:695-766 (update).


2. The relay is one shared world — stamp everything

The single most important thing to understand before designing your messages.

The live server keeps one global world with a flat peer list. Concurrent matches are not isolated from each other: an event you broadcast is fanned out to peers who may be playing an entirely different game. There is no per-room scoping to lean on.

So every payload the demo sends begins with a 4-byte room stamp, and every inbound event whose stamp is not ours is dropped before it can touch game state:

protocol.room_stamp
uint32_t room_stamp(const std::string& session_id) {
    uint32_t h = 2166136261u;           /* FNV-1a */
    for (size_t i = 0; i < session_id.size(); ++i) {
        h ^= (uint8_t)session_id[i];
        h *= 16777619u;
    }
    return h ? h : 0xA5A5A5A5u;         /* never zero: 0 == uninitialised payload */
}

Stamp from the session id, not from whatever identifier the player typed. It is the one value both sides are guaranteed to hold: the host learns it from /matchmake, and a guest who joined by short code learns it from /resolve, which never echoes the session handle back. Stamp from the handle instead and a code-joining guest tags its traffic differently from the host, and each side silently discards the other's frames as foreign.

The check happens at the very edge of the inbound path, before anything is decoded:

App.on_wire_event
DecodeResult dr = decode_frame(room_stamp_, event_id, payload, len, f);
if (dr == DECODE_WRONG_ROOM) { ++dropped_foreign_; return; }

Verified against: samples/battleship3d/src/core/protocol.cpp:61-70 (room_stamp), samples/battleship3d/src/core/protocol.cpp:207-255 (decode_frame), samples/battleship3d/src/app/app.cpp:396-471 (on_wire_event).

Start your game state before you open the socket

This one cost an afternoon and is worth more than any other paragraph on this page.

The inbound callback goes live the instant the transport connects, and the relay immediately hands over whatever is already in flight — including the HELLO an opponent has been retransmitting every 500 ms while they waited for you. The demo initialised its match on the connected transition, one step later, which wiped a HELLO that had already arrived. The reliable stream had already ACKed that frame, so the opponent stopped resending, and both players sat in a lobby that never filled.

The window is the gap between "socket open" and "handshake accepted", so the longer your opponent had been waiting, the likelier they landed inside it: joining a minute later failed roughly one time in three, joining immediately almost always worked. That shape — works when I test it, fails for real users — is what an initialisation-ordering bug looks like from the outside.

The rule that falls out of it: anything that can receive must be constructed before the thing that receives is switched on. Not on the next state transition, not once the handshake completes. The fix was moving one line above session_.start().

Who makes the HTTPS call: the game, or the core?

Both work, and the demo ships both — BS3D_HTTP=native (default) uses its own in-process client, BS3D_HTTP=core routes the identical calls through liblattice's mediated fetch. Each passes the live smoke 10/10 on Linux and Windows against the real control plane.

The interesting part is why the core path is not the default, because it is a genuine API-shape lesson rather than a bug. The core's fetch is owned by a runner and its API is tick-thread bound: lattice_http_request* and lattice_http_poll must be called from the thread that calls lattice_runner_tick. A game's login does not look like that. Sign-in, matchmake and resolve run early, often on a worker, and they run before any runner exists — producing the endpoint a runner will later be created to dial is the entire point of making them.

So the adapter has to stand up a runner it never connects, plus a thread that does nothing but tick it, and marshal the game's worker across. It works; you can read it in samples/battleship3d/src/net/http_core.cpp. But standing up a networking runner in order to make one HTTPS call before you have a session is a shape worth noticing:

If you are designing an API like this, notice where the caller actually is

The fetch was designed for a server module already inside the tick loop — genuinely the right shape for that caller. The flagship client use case, logging in, sits before the loop exists and on a different thread. An API can be correct for its original caller and still be awkward for the one that matters most, and the way you find out is by making a real program use it rather than a test.

Be honest with yourself about what this is. The stamp is a de-multiplexing tag, not a security boundary. It stops you from accidentally processing another match's traffic. It does not stop anyone who knows your room code from deliberately sending you well-formed events — and /resolve performs no membership check, so the code is a bearer secret in practice.

It also does not tell you WHICH peer sent a frame. The relay reports sender == 0 for everything it relays, so a third client in the room — or one left over from an earlier match — is indistinguishable from your opponent. That matters most for acknowledgements: an ack carries only "I have everything up to N", and both players number their first message 1, so a stranger's ACK(1) clears your first message from your retransmit queue. Every frame in this demo therefore carries a random per-instance id, acks included, and anything from an id that is not your opponent's is dropped and counted.

The test for this is worth copying: spawn a "ghost" — a peer that shares your room stamp but is not your opponent — and have it ACK your sequence 1. Without sender ids your retransmit queue empties and you stop resending to a friend who has not arrived yet; with them, the queue is untouched. That is a deterministic check, not a statistical one, which matters because the live symptom appeared about one run in three and would happily "pass" a short test run. Shortening the code to six characters narrowed that margin deliberately, so the platform closed the gap on the other side: 387 million combinations, a six-hour lifetime, and a per-IP token bucket on /resolve (~5 sustained per minute, bursting to 10) that makes grinding the space from one address pointless. Both /resolve shapes are limited — leaving the handle open would just move the grinding one field to the left. A distributed attacker is a different threat, and the answer there is per-code attempt tracking rather than per-IP.

Make a throttled join distinguishable from a wrong code

A 429 and a 404 mean opposite things to a player: one says wait, the other says check what you typed. Show the same message for both and a throttled player concludes their friend read the code out wrong, retypes it, and spends more of the very budget that just ran out. The demo maps 404, 429, 409 (room full) and 403 (unrecognised game key) to four distinct sentences, and the unit suite asserts that the throttled one explicitly absolves the code.

The demo's integration test injects a foreign-room frame and asserts both that it is counted as dropped and that the match is undisturbed, because a filter nobody tests is a filter that quietly stops working.


3. Do not trust the reliable flag

lattice_send_event takes a reliable argument. Over the real UDP transport it is currently discarded:

Runner.send_event
lattice_result Runner::send_event(uint16_t event_id, lattice_event_target target,
                                  lattice_netid netid, uint64_t peer,
                                  const uint8_t* payload, uint32_t len, int reliable) {
    (void)reliable; /* loopback is always reliable; flag is plumbed for the real transport */

Verified against: reference/src/runner.cpp:1529-1531 (send_event), reference/src/runner.cpp:1494-1496 (send_rpc), reference/include/lattice/lattice.h:683-686 (lattice_send_event).

Why the conformance suite doesn't catch this

Worth internalising, because it shapes how much you should trust green test output in general.

The suite's custom-event checks — N.a GLOBAL (object-less) reliable event delivered EXACTLY ONCE to ALL, N.a reliable-ordered events delivered IN ORDER, N.d event payload round-trips BYTE-EXACT — all pass, and they are real: they drive 80 events through 30% loss, reorder and duplication and come out exactly-once and in order.

But they drive the reliability engine directly through the lattice_test_event_* hooks, not through Runner::send_event. The runner never routes events into that engine. The public header says so outright:

the reference Runner skeleton wires up transport + the object store + RPC, but does NOT run a live per-connection reliability / congestion / prediction / fragment endpoint inside the loopback pump (those §5/§6/§7/§11 subsystems are exercised through the lattice_test_* hooks, not the runner).

Verified against: reference/include/lattice/lattice.h:430-437.

So the engine is proven and the wiring is absent. A green suite tells you a component works, not that your code path reaches it.

What to build instead

Battleship is turn-based, so there is never a reason for two messages to be in flight. That makes stop-and-wait ARQ sufficient, and it is genuinely small:

  • One monotonically increasing sequence number per sender, starting at 1 (0 means "nothing acked").
  • Send the head of the queue; resend it every 500 ms until it is acked.
  • Acks are cumulative and unsequenced — ACK(n) means "I have everything through n".
  • A duplicate or out-of-order frame is dropped and re-acked.
  • If the head goes unacked for 10 s, declare the peer gone.
ReliableStream.on_recv
InboundKind ReliableStream::on_recv(uint32_t seq) {
    /* Any inbound frame owes the peer an ack -- including duplicates and out-of-order
     * frames. Re-acking on a duplicate is what unsticks a sender whose ack was lost:
     * without it, the sender resends forever and the receiver stays silent. */
    ack_pending_ = true;
    if (seq == expected_) { expected_++; delivered_++; return IN_DELIVER; }
    if (seq <  expected_) { dupes_++;                  return IN_DUPLICATE; }
    future_++;                                          return IN_FUTURE;
}

That re-ack-on-duplicate line is the one people leave out. Without it a lost ack (not a lost message) deadlocks the stream permanently: the sender retries forever and the receiver, having already delivered the frame, says nothing.

Verified against: samples/battleship3d/src/core/reliable.cpp:86-105 (on_recv), samples/battleship3d/src/core/reliable.cpp:40-66 (poll_send), samples/battleship3d/src/core/reliable.cpp:68-77 (on_ack), samples/battleship3d/src/core/reliable.h:34-39 (kResendIntervalMs, kPeerTimeoutMs).

Time is injected rather than read from a clock, so the unit suite drives loss, duplication and reordering deterministically with no sleeping.

Gate your peer timeout on 'opponent known'

The host's first HELLO is broadcast into an empty room and goes unacked for as long as it takes a friend to join. A timeout that starts counting immediately will kill every lobby after ten seconds. Only arm it once you have actually heard from someone.

Verified against: samples/battleship3d/src/app/app.cpp:650-693 (pump_network).

Chat rides the same stream — and what that costs

Chat uses the same sequence stream as game messages. The benefit is that chat becomes fully reliable and ordered relative to gameplay for no extra machinery.

The cost is head-of-line blocking in both directions. A chat line waits behind an unacked shot — fine. But a chat line dropped mid-turn also holds up the FIRE queued behind it until the 500 ms resend lands, so a lost chat packet visibly stalls the game for a beat. Turn-based play absorbs that. A twitch game should give chat its own stream, which is a second ReliableStream instance and a second event-id range.


4. Identity and turn order

Two facts remove the obvious approaches:

Peer ids are not usable. A relayed event arrives with sender == 0 regardless of who sent it:

Runner.handle_event
/* Client received a server-originated event (sender 0 == authority). */
if (have_cb_ && cb_.on_event)
    cb_.on_event(cb_.user_data, event_id, netid, /*sender*/0, payload, len);

And lattice_runner_local_peer_id() honestly reports LATTICE_LOCAL_PEER_UNKNOWN on a plaintext handshake, because that handshake carries no id assignment. It is not a bug to work around — it is the API refusing to invent a plausible-looking wrong number.

Verified against: reference/src/runner.cpp:908-955 (handle_event), reference/include/lattice/lattice.h:401-402 (lattice_runner_local_peer_id).

With exactly two players in a room, that is survivable: anything you did not send is theirs.

"Who created the room" is not reliable either, because of the find-or-create behaviour in section 1. So HELLO carries a random 64-bit tiebreak, and turn order resolves like this:

Match.on_hello
if ((m.is_host != 0) != is_host_) {
    first_fire_ = is_host_;                    /* roles disagree: the creator opens */
} else if (m.tiebreak != tiebreak_) {
    first_fire_ = (tiebreak_ < m.tiebreak);    /* both claimed the same role */
} else {
    /* identical 64-bit ids: refuse rather than have both players open fire */
}

Both sides compute the same answer from the same pair of ids, with no round trip. In the ordinary create-then-join case the tiebreak is never consulted.

Verified against: samples/battleship3d/src/core/match.cpp:87-119 (on_hello).

The general pattern: symmetric tiebreak

Recommended for any two-peer game on this platform, not just this one — because the find-or-create behaviour in section 1 applies to everyone, so "both peers believe they are the host" is a case you will hit whether or not you plan for it.

The rule that makes it work: decide asymmetric roles from data both sides already hold, with a deterministic comparison, so neither peer has to ask. A negotiation needs a round trip and a tiebreak for the negotiation itself; a comparison of two exchanged random numbers needs neither and cannot disagree.

It generalises past turn order to anything needing exactly one owner — who spawns shared objects, who arbitrates a draw, who picks the seed. Three things to get right:

  • Draw from a real random source, per session. A counter or a hash of the player name collides, and a colliding tiebreak is precisely the case it exists to resolve.
  • Make it wide. 64 bits makes accidental collision irrelevant.
  • Handle equality explicitly. Equal ids mean a peer that is not generating them properly, so failing loudly beats both players opening fire. The demo refuses the match and says why.

If you have more than two peers, sort on the tiebreak instead of comparing — the same rule, the same properties.


5. Payload budgeting

Event payloads are hard-clamped at 1024 bytes and there is no fragmentation — a larger message is truncated, not split:

Runner.handle_event
uint8_t payload[1024] = {0};
uint32_t len = r.read_bytes(payload, sizeof(payload));
if (len > sizeof(payload)) len = sizeof(payload);

Verified against: reference/src/runner.cpp:908-918 (handle_event).

Note the clamp on the line after the read: read_bytes returns the length the sender declared, which is attacker-controlled. The conformance checks Q.1a oversized string length surfaced, not silently truncated and Q.2b oversized bytes read did not over-read / hang cover the codec's behaviour here. Apply the same discipline to your own decoders — every length byte in the demo's protocol is checked against the bytes that actually arrived before it is used.

The demo's whole protocol was sized to sit far under the clamp:

Event ID Body Total bytes
HELLO 100 is_host u8, tiebreak u64, len u8, name 18–42
READY 110 32-byte commit 40
FIRE 120 cell u8 9
RESULT 121 cell, outcome, ship, all_sunk 12
REVEAL 130 15-byte fleet + 16-byte salt 39
CHAT 140 len u8, text (≤ 200) 10–209
REMATCH / BYE 150 / 160 8
ACK 199 ack in header 8

Every frame carries | room u32 | seq u32 | first; ACK carries the acked sequence in the second slot instead. Largest possible message is a 209-byte CHAT, five times under the clamp. Chat was capped at 200 characters for this reason, not for UI tidiness.

Verified against: samples/battleship3d/src/core/protocol.h:65-71 (kMaxChatBytes, kMaxFrameBytes).

Fixed-width little-endian fields are written byte by byte, so encoding never depends on host endianness — the same build talks to itself across Linux and Windows.


6. Hidden boards: client-held secrets + commit-reveal

Battleship needs each player's board hidden. The server runs no game logic, so it cannot referee. That leaves the boards client-side, which means every shot result is attested by your opponent and cannot be checked while the game is running. A modified client could answer "miss" to everything.

The demo does not pretend to prevent that. It makes it provable afterwards:

sequenceDiagram
  participant A as Player A
  participant B as Player B
  Note over A,B: READY - before a single shot
  A->>B: READY: SHA-256(fleet || salt)
  B->>A: READY: SHA-256(fleet || salt)
  Note over A,B: ...the whole game, FIRE / RESULT only...
  Note over A,B: GAME OVER
  A->>B: REVEAL: 15-byte fleet + 16-byte salt
  B->>A: REVEAL: 15-byte fleet + 16-byte salt
  Note over A: re-hash, then REPLAY every shot A fired

Verification is three checks, and the third is the one that matters:

Match.verify_reveal
/* 1. the revealed fleet must hash to the commit sent BEFORE any shot was fired */
compute_commit(m.fleet, m.salt, recomputed);
if (!digest_equal(recomputed, enemy_commit_)) return VERIFY_COMMIT_MISMATCH;

/* 2. the board they committed to must have been legal */
if (!fleet_valid(m.fleet)) return VERIFY_ILLEGAL_FLEET;

/* 3. replay OUR ENTIRE SHOT HISTORY against that board and check every answer */
for (size_t i = 0; i < shots_.size(); ++i) {
    FireOutcome truth = replay.receive_fire(shots_[i].cell, &ship);
    if ((uint8_t)truth != shots_[i].outcome)          return VERIFY_RESULT_MISMATCH;
    if (truth != FIRE_MISS && ship != shots_[i].ship) return VERIFY_RESULT_MISMATCH;
    if ((uint8_t)replay.all_sunk() != shots_[i].all_sunk) return VERIFY_RESULT_MISMATCH;
}

The commit alone only proves they did not swap boards. The replay proves they did not lie about the results of a board they kept. Both are needed.

The 16-byte salt is not decoration: the fleet encodes to 15 bytes, so the space of legal boards is small enough to brute-force from an unsalted commit.

Verified against: samples/battleship3d/src/core/match.cpp:271-305 (verify_reveal), samples/battleship3d/src/core/protocol.cpp:325-331 (compute_commit), samples/battleship3d/src/core/match.cpp:121-141 (commit_fleet).

Generalising this. Any hidden state a server cannot referee — a hand of cards, a chosen route, a sealed bid — can use the same shape: commit a hash before play, reveal the preimage after, and replay the interaction against the reveal. It converts "trust your opponent" into "cheating is detected and attributable", which for a friendly game is usually enough.


7. Testing a networked game without a network

The demo's layering exists for testability, not tidiness:

src/core/    PURE: rules, protocol, ARQ, match state machine.  No I/O, no clock, no raylib.
src/net/     I/O: JSON, HTTP, DNS, backend, and the ONE file that touches the Lattice ABI.
src/app/     the controller. Owns Match + reliability + session. No raylib.
src/render/  the ONLY raylib file.
src/main.cpp wires App to Renderer. Includes no raylib header at all.

Because App has no renderer, the integration test builds two of them in one process and plays a complete match over real UDP against a local lattice_persistent_server — the same code the window runs, minus the window. It asserts the handshake, HELLO, both commits, a full 17-shot game, chat in both directions interleaved with game traffic, the agreed outcome, both reveals verifying, a foreign-room frame being dropped, every queued frame draining, and a rematch into round two.

Two things this structure bought that are hard to get any other way:

  • Loss/dup/reorder tested without a network. ReliableStream takes time as a parameter, so the unit suite scripts three consecutive drops and asserts the fourth transmission lands — in microseconds, deterministically, every run.
  • A render smoke test. --smoke-frames N draws N frames and exits 0 without touching the network, which runs under Xvfb in CI and under wine for the Windows build.

Results at time of writing: 230/230 unit (Linux and wine), 38/38 integration, render smoke passing on both targets, and 9/9 on a live smoke against the real services.


8. Wiring analytics — and the credential problem you will hit

Telemetry is the part most samples skip, so here is the whole thing including the bit that does not work yet.

The contract. POST /v1/events takes a batch; the server stamps the gameId from the authenticated key and never trusts it from the body:

{"events":[
  {"type":"SessionStart","sessionId":"bs3d-8f2a","tags":{"game":"battleship3d"}},
  {"type":"Custom","name":"match_ended","sessionId":"bs3d-8f2a",
   "values":{"round":1,"shots_fired":17,"chat_lines":4},
   "tags":{"outcome":"win","fair_play":"VERIFIED FAIR"}}
]}

Typed events (SessionStart, SessionEnd, MatchmakingRequest, MatchmakingSuccess, Heartbeat, CheatViolation, …) get first-class metrics for free; anything else is Custom plus a name, which becomes a dev-defined counter. The demo maps its room lifecycle onto the typed matchmaking events — a room is a matchmaking result here — and uses Custom for match_started / match_ended. The one worth stealing is putting the commit-reveal verdict on match_ended as a tag: how often the anti-cheat actually caught something is not observable anywhere else.

The rule the emitter obeys: analytics must never be able to affect the game. Not its timing, not its outcome, not its ability to start. Every send happens on a background thread, events are batched on a 2-second flush, the queue is capped and drops oldest, and every failure is swallowed after a line in the log. A service that is down, slow, or unreachable is indistinguishable from one that is up. If that trade ever looks wrong, send less — do not block the frame.

Verified against: samples/battleship3d/src/net/analytics.cpp:131-155 (run), samples/battleship3d/src/net/analytics.cpp:157-205 (post_batch), samples/battleship3d/src/net/analytics.h:64-131 (Analytics).

Two auth shapes, and why the second one had to exist. /v1/events accepts either:

Mode Credentials Who
server X-Lattice-Api-Key + X-Lattice-Api-Secret the game's own backend — fully trusted
client X-Lattice-Api-Key + Authorization: Bearer <player token> a game client

The demo uses client mode, because it has to. The api key is public by design; the api secret is not, and a secret compiled into a binary players download is extractable by anyone who downloads it — they could then forge that game's telemetry at will. Before the platform gained client mode, the key alone was simply refused:

key only            -> {"error":"Invalid game API credentials."}   HTTP 403
key + empty secret  -> {"error":"Invalid game API credentials."}   HTTP 403

In client mode the key only says which game; the player's own access token — the same one /guest already issued for matchmaking — is what authenticates. Nothing secret is compiled in.

Client-submitted data is player-attested, and the server treats it that way

Opening ingestion to any authenticated player is a real trade, so the server does not take the client's word for anything it can determine itself:

  • userId is stamped from the verified token's sub and overwrites whatever the body claimed — otherwise any player could attribute events to any other player. Same principle that has always kept gameId server-stamped from the key.
  • A tighter batch cap (32, against 1000 for the trusted path), so one request cannot enqueue unbounded work.
  • Per-player rate limiting, keyed on the token rather than the IP — a household or a NAT shares an IP legitimately; what needs limiting is one player flooding.
  • Rows are tagged source=client, so queries can separate player-attested data from server-attested data. Server rows stay untagged and unchanged.

Design it this way round and client analytics is a reasonable thing to ship. Skip the stamping in particular and you have built an attribution forgery endpoint.

Verifying it without production credentials. Run the real analytics service locally against a stand-in for the console's POST /auth/validate-key (the only thing it needs to resolve a key to a gameId) and point the game at it. Two gotchas cost real time: .NET sends those validation calls with Transfer-Encoding: chunked and no Content-Length, so a stand-in that reads Content-Length bytes silently reads nothing and every request 403s; and the RBAC on the query endpoints comes from an X-Lattice-Role header the console sets on GET /games, whose returned slice is the owned-game set. With both handled, the demo's events ingest and aggregate for real:

analytics: sent 4 event(s) [client mode] -> HTTP 202 {"accepted":4}

active_users           total=1
ccu                    total=1
custom.match_ended     total=1
custom.match_started   total=1
matchmaking_requests   total=1
session_count          total=1

active_users and ccu only populate in client mode, and that is the server-side userId stamping visibly earning its keep: the events carry a real, verified subject, so distinct-user metrics have something to count. The same run in server mode with no userId in the body leaves both at zero.

And against production. The same emitter, unchanged, with a real /guest token from the live auth service and only the public key compiled in:

analytics: client mode armed (player token, 500 chars)
analytics: sent 2 event(s) [client mode] -> HTTP 202 {"accepted":2}
analytics: sent 4 event(s) [client mode] -> HTTP 202 {"accepted":4}
emitter: queued=6 sent=6 failed=0 dropped=0

Six events across two batches because the sender flushes on a timer rather than per event. The four ways of getting the credentials wrong all fail the same way, and the batch cap is enforced where it is documented:

key only, no bearer          -> 403 {"error":"Invalid game API credentials."}
key + garbage bearer         -> 403 {"error":"Invalid game API credentials."}
bearer only, no key          -> 403 {"error":"Invalid game API credentials."}
unknown key + valid bearer   -> 403 {"error":"Invalid game API credentials."}
40-event batch (cap is 32)   -> 400 {"error":"Batch exceeds the 32-event limit."}

One deliberate choice is visible there: every credential failure returns the same message. Distinguishing "unknown key" from "bad token" would tell someone probing the endpoint which half of their guess was right.


9. Honest limitations

Carried over from the sample's own README, because a case study that only lists wins is not much of a case study.

  • Results are opponent-attested until the reveal. Mid-game you are trusting the other client. Commit-reveal proves honesty afterwards; it does not stop someone ruining a game in progress. Server-side authority would, but the live server runs no game logic.
  • Room stamping is a de-mux tag, not isolation. Anyone with your room code can send you well-formed events, and /resolve does not check membership.
  • A stranger can land in your room via find-or-create while you wait for a friend. The tiebreak means you get a playable match rather than a deadlock, but it is not the private-room semantics a code-sharing UI implies.
  • Chat shares the game stream, so a dropped chat line delays the game message behind it by up to one resend interval.
  • The connect token is sent but not validated by the live server today. Send it anyway.
  • Two players only. The turn model, the "anything I did not send is theirs" inference, and the single reliable stream all assume exactly two peers.
  • HTTPS is in-process, and getting there took a detour worth repeating. The core's mediated fetch has no TLS, no DNS and no custom headers, so the demo needs its own HTTP client. The first version shelled out to the curl binary via popen. That shortcut cost three things: it shipped a Windows-only bug (a literal newline in the command string — POSIX sh tolerates one inside double quotes, cmd.exe treats it as end-of-command, so Windows ran curl … -w " with no URL); it made the program depend on an external executable being present and first on the search path, and Windows searches the current directory before PATH, so a stray curl.exe beside the game silently wins; and it could not be tested end to end, because wine ships no curl.exe.

It is now WinHTTP on Windows and dlopen'd libcurl on Linux, both behind the same HttpClient interface. No shell, no argv, no temp files — bodies stay in memory. The payoff beyond correctness is testability: wine implements winhttp.dll, so the Windows build now runs the real live smoke under wine, sign-in and all.

If you take one thing from this section: a subprocess is not a cheap HTTP client. It is a shell, a PATH lookup, and an argument-quoting contract you did not want to own.

Verified against: samples/battleship3d/src/net/http.cpp:118-230 (post_json), samples/battleship3d/src/net/http.cpp:35-70 (split_url), samples/battleship3d/src/net/http.cpp:23-33 (bearer_token_safe).

One core limitation you will hit immediately

The sample cannot link the stock reference/build/liblattice.so, because the client half of the real UDP transport cannot reach a server that is not on loopback:

UdpTransport.connect
/* Bind to an ephemeral local port on the loopback interface so the server learns a stable
 * source address to reply to. Port 0 => OS picks a free port. */
if (!sock_.bind("127.0.0.1", 0)) { sock_.close(); return false; }

A UDP socket bound to 127.0.0.1 cannot send to a routable address — the kernel rejects it:

bound to ('127.0.0.1', 54733)
sendto public IP from 127.0.0.1-bound socket: FAILED -> OSError [Errno 22] Invalid argument
unbound socket sendto: SENT 4

Measured against the live server:

Result
stock core, hostname connect() → error, "no listener"
stock core, dotted quad connect() → OK, but no handshake, ever
patched core, hostname "handshake accepted" — CONNECTED

Verified against: reference/src/transport_udp.cpp:66-86 (connect).

The sample works around it by building its own core from a snapshot with one patch: an environment override, LATTICE_CLIENT_BIND_ADDR, that defaults to the existing 127.0.0.1 so an unset environment is byte-for-byte the stock behaviour. The demo sets it to 0.0.0.0. DNS is handled in the sample's own code instead, keeping the core delta to a single line.

Verified against: samples/battleship3d/src/net/session.cpp:23-47 (select_udp_transport).

This is queued for upstreaming, and it affects every binding, not just C++ — Unity and Godot clients hit the same wall the moment they point at a non-local server. If you are reading this after that lands, you will not need the workaround; check whether lattice_runner_connect reaches a remote host before building one.


Where to go next

The full source, build scripts for both platforms, and the sample's own README are in samples/battleship3d/.