Skip to content

Co-op Determinism Coverage — Design

Date: 2026-07-14 Status: Approved (Chris, 2026-07-14) Origin: overnight-review triage item — “No 2-pilot determinism test; state_checksum()/snapshot_string() omit drone state (c1).”

Two gaps in the determinism keystone that protect Phase-3 co-op netcode:

  1. No 2-pilot determinism test. tests/test_determinism_survival.gd runs a single pilot only; it never calls add_player2(), so the 2-pilot tick() path is never proven byte-identical across two same-seed runs. The co-op sim (per-pilot arsenals, per-pilot upgrades, per-pilot drones — all landed 2026-07-09..11) has no determinism guard.
  2. state_checksum()/snapshot_string() omit drone state. Both already loop over all pilots for pos/hp/xp/level (multi-pilot player state IS covered), but neither includes the drones pool. sim/drone_director.gd:508 documents this as deliberate — “NOT in snapshot_string()/state_checksum() (drones are opt-in, so this never affects determinism)” — a rationale that held only because no determinism test ever deployed a drone. A co-op drone desync (drones are per-pilot now) would go undetected.
  • The existing single-pilot baseline run drives InputState.new(dir) with no decoy press, so no drone is ever deployed → the drones pool is empty throughout that run.
  • Appending a for d in drones: loop to state_checksum()/snapshot_string() therefore leaves the existing single-pilot output byte-identical (empty loop contributes nothing) → the existing baseline 4217109746/3312602286 is unchanged, no re-pin.
  • BUT the per-pilot drone_charge scalar accrues every tick unconditionally (drone_director.gd:102, not gated on a loadout), so it is nonzero after 600 ticks. Including it in the checksum WOULD change the existing baseline. It is therefore deliberately excluded (see below).

Decision (Chris, 2026-07-14): drone-inclusive

Section titled “Decision (Chris, 2026-07-14): drone-inclusive”

Fold the drones pool into the checksum and add a 2-pilot determinism test that deploys a drone. Overturns the “drones never affect determinism” exclusion for the pool, keeps it for the accruing scalar.

1. state_checksum() — append the drones pool (sim/sim.gd)

Section titled “1. state_checksum() — append the drones pool (sim/sim.gd)”

After the existing gems loop, before return hash(parts):

for d in drones:
parts.append(d.pos)
parts.append(d.vel)
parts.append(d.hp)
parts.append(d.life)
parts.append(d.charge)
parts.append(d.pulse_timer)
parts.append(d.phase)
parts.append(pilots.find(d.owner)) # owner as a stable pilot index (co-op)

Deliberately excludes the per-pilot drone_charge scalar. Rationale: it accrues every tick, so including it would re-pin the existing keystone value for near-zero gain — a drone_charge desync can only matter by changing WHEN a drone deploys, which changes the pool and is already caught here. The pool is the desync-prone state; the scalar’s effects surface through it. d.charge (the per-DroneState charge field, distinct from pilot.drone_charge) IS included — it only exists once a drone is in the pool, so it is baseline-safe.

pilots.find(d.owner) returns the owner’s index in pilots (or -1) — a stable, deterministic integer that verifies drone ownership is assigned consistently in co-op.

2. snapshot_string() — append a per-drone segment (sim/sim.gd)

Section titled “2. snapshot_string() — append a per-drone segment (sim/sim.gd)”

Inside a for d in drones: loop (placed after the aggregate e=… a=… pr=… g=… k=… line, before the per-pilot xp/lv loop, or at the end — any fixed position), append a compact per-drone segment, e.g.:

for d in drones:
s += " d=(%.3f,%.3f)" % [d.pos.x, d.pos.y]

No drone count is added to the aggregate line (that would change the single-pilot format and break the byte-identical output). With an empty pool the loop appends nothing, so test_pilots_query.gd’s old-vs-new-tick byte-match and test_determinism.gd’s trace comparisons are unaffected.

3. Update the stale comment (sim/drone_director.gd:508)

Section titled “3. Update the stale comment (sim/drone_director.gd:508)”

Change the comment to reflect that deployed-drone pool state now IS in state_checksum()/snapshot_string(); only the monotonic per-pilot recharge scalar (pilot.drone_charge) stays excluded, and its effects surface via the pool (deploy timing).

4. New test tests/test_determinism_coop.gd

Section titled “4. New test tests/test_determinism_coop.gd”
  • Two Sims, seed 1234, enable_survival() on both, add_player2() on both.
  • Deploy a drone for P2 identically in each sim up front: deploy_drones(sim, sim.pilots[1], [SPEC]) where SPEC = {"klass": "sentinel", "life": 1.0, "dmg": 1.0, "radius": 1.0, "heal": 0.0, "extras": 0} (the shape used in tests/test_drones.gd). Deploying directly (not via the decoy-charge/button path) keeps the setup deterministic and focused on the drone’s per-tick behavior.
  • Run 600 ticks. Each tick builds a 2-pilot input array: P1 moving on one Lissajous path, P2 on a different one (both non-trivial so the pilots diverge and exercise _nearest_pilot), and tick both sims with the identical input sequence. Use the canonical tick(inputs: Array[InputState]) (or tick_single(input, input2)), whichever the 2-pilot path expects — confirm against Sim.tick.
  • Assert a.state_checksum() == b.state_checksum() and a.snapshot_string() == b.snapshot_string() — the primary guarantee: the co-op sim (2 pilots + a deployed per-pilot drone) is byte-identical across two same-seed runs.
  • Pin a new, separate 2-pilot absolute baseline: assert_eq(a.state_checksum(), <value>) and assert_eq(a.snapshot_string().hash(), <value>). The values are captured from the first green run (standard determinism-baseline procedure — read the printed value, pin it, document the run parameters in a comment). These are NEW baselines, not a change to the existing single-pilot ones.

/sim-internal. The existing single-pilot baseline is unchanged (verified: empty pool + scalar excluded). The a==b assertion in the new test is the real guarantee; the pinned absolute values catch future drift. This is additive determinism coverage, not a change to the existing keystone value.

Pure additions. The only risk is the new pinned baseline being wrong — mitigated by the a==b assertion running first (if the sim weren’t deterministic, a==b fails before the absolute pin is even reached) and by capturing the pinned value from a confirmed-green run.

  • The new test_determinism_coop.gd is itself the test.
  • Regression: test_determinism_survival.gd (single-pilot absolute baseline) must still pass unchanged — the explicit proof that Components 1–2 didn’t perturb the existing keystone.
  • Regression: test_pilots_query.gd and test_determinism.gd (snapshot_string byte/relative comparisons) must still pass unchanged.
  • Full suite: 0 failing.
  • Including the per-pilot drone_charge scalar in the checksum (excluded by design — would force an existing-baseline re-pin for near-zero gain).
  • Forcing per-pilot level-ups / upgrade rolls in the co-op test (the upgrade RNG stream is already determinism-tested in test_determinism.gd; YAGNI here — the co-op test’s job is the 2-pilot + drone tick path).
  • Networked (M-B/M-C) determinism — that unmerged worktree is separate.