# Tasks — Easy-Multiplayer

The active roadmap is the v2 redesign. For goal-level definitions of done (deliverables, success criteria, verification), see `GOALS.md`. For the canonical design vision, see `easy_multiplayer_redesign_concretized_architecture.md`.

## Status Legend

- `[ ]` Pending
- `[~]` In Progress
- `[x]` Complete
- `[!]` Blocked

---

## Phase A — Foundation (no protocol changes yet)

Builds the substrate the rest of the work runs against. Each item is reversible / non-protocol-changing.

- [x] Pivot decision recorded; v2 design canonized
- [x] Documentation refresh + `GOALS.md` created (2026-05-25)
- [x] **A1.** Define abstract `Transport` interface — `transports/Transport.js` + finalized `TRANSPORT_SPEC.md` (2026-05-28)
- [x] **A2.** Build `MemoryTransport` + `VirtualClock` + `NetworkSim` (latency, jitter, loss, partition) + executable conformance suite & Node-12 runner (`npm run test:harness`) (2026-05-28)
- [x] **A3.** Build `PeerHarness` + `SceneRunner` for N-peer in-process scenarios + 4 assertion primitives (`ReferenceNode` stub; real-engine wiring deferred to A5 — KNOWN_ISSUES #7) (2026-05-28)
- [x] **A4.** Draft first English scenarios in `TEST_SCENARIOS.md` (44 across categories 1–5; surfaced 7 new open questions → KNOWN_ISSUES #8–14) (2026-05-28)
- [x] **A5.** Extract `transports/TrysteroTransport.js` (only trystero importer); `WorldNetworkCommunicator` now takes an injected `Transport`; `EasyMultiplayer` constructs/injects it (2026-05-29). Verify `grep` for trystero imports outside `transports/` is clean. Browser-page criterion not directly verifiable — see PROGRESS_LOG.
- [x] **A6.** Fix Node version / Vitest compatibility so the test suite actually runs (2026-05-29). Root cause: default `node` on PATH was system v12; Vitest 4 needs Node ≥18. Pinned via `.nvmrc` (20) + `engines` + `.npmrc engine-strict` + a `pretest` Node-version guard. `npm test` → **6 files / 98 tests pass** on Node 20 & 24.

## Phase B — Protocol Redesign (TDD against the Phase A harness)

Each item is "make these scenario IDs pass". Roughly in dependency order:

- [~] **B1.** Sparse change-only input protocol + silence semantics (2026-05-29). Core landed: `SparseInput.js` (encoder/decoder, hold-last, reorder-safe, idempotent) + `test-harness/SparseInputNode.js`. Tested: S-003-01/02/04/06/08/09, S-004-02/06 pass (unit + harness); 100-tick held input → 1 packet. **Remaining for [x]:** migrate the v1 `RollbackNetcode` send path to sparse — staged behind B2 (liveness) + B9 (finalization). See PROGRESS_LOG.
- [x] **B2.** transport-level heartbeat + liveness separation (2026-05-29). Reusable pure `transports/HeartbeatLiveness.js` (emit-on-cadence + last-seen map + timeout sweep); opt-in `{ heartbeat }` mode on `MemoryTransport`; `NetworkSim` suppresses idealized join/left for `_managesOwnLiveness` transports so partitions surface only via heartbeat silence. Liveness is strictly the heartbeat channel — app traffic never sustains/revives a peer (resolves S-005-04 open Q). Tests: heartbeat-liveness.test.js (6 unit), liveness-harness.test.js (4) + selftest-b2.mjs (4) covering S-005-01/02/03, S-004-05. Defaults intervalMs=500/timeoutMs=2000 in PROTOCOL_SPEC. **Full suite: 126 vitest pass + harness green.**
- [~] **B3.** `getLocalInputs(localGameState)` context-aware intent construction (2026-05-29). Core landed: `LocalIntent.js` (`LocalIntentSource` over a `SparseInputEncoder`; `null`/`undefined` = first-class PASSIVE, not a neutral object; `fromFieldSamplers` migration shim) + `test-harness/LocalIntentNode.js`. Public `EasyMultiplayer.getLocalInputs(fn)` wired into the facade, takes precedence over `defineInput`; default `TrysteroTransport` now imported LAZILY in `start()` so the facade is importable/testable under plain Node ESM. Resolves S-003-03 (passive=excluded), S-003-05/KNOWN_ISSUES #13 (pre-history baseline=null), adds S-003-10 (context-bound intent). Tests: local-intent.test.js (12 unit), local-intent-harness.test.js (3), easy-multiplayer-getlocalinputs.test.js (3 facade) + selftest-b3.mjs (3) — S-003-03/05/10, S-004-04. **Remaining for [x]:** SPARSE-send + passive-rollback-pressure release rides the same input-path migration as B1 (Goal B9). See PROGRESS_LOG.
- [~] **B4.** Predicate context freezing (Option C: explicit ctx parameter + debug-mode dup-and-compare) (2026-05-29). Core landed: `QueryContext.js` — `QueryLog` (debug deep-clones ctx → frozen snapshot for all re-eval + post-predicate compare → names mutating predicate; production stores ctx by reference, zero clone/compare), `cloneCtx` (host `structuredClone` w/ cycle-safe recursive-walker fallback for Node 12), `ctxEqual`, `recheck` (decisionless earliest-flip detection) + `test-harness/QueryNode.js`. Resolves KNOWN_ISSUES #5 (clone choice); adds TEST_SCENARIOS categories 18 (S-018-01/02/03) + 19 (S-019-01/02). Tests: query-context.test.js (17 unit), query-context-harness.test.js (3) + selftest-b4.mjs (3, also run on Node 12 to exercise the clone fallback). Production zero-overhead proven structurally (clone-fn spy: 0 calls). **Remaining for [x]:** wiring the 3-arg `query(playerId, ctx, predicate)` into v1 `RollbackNetcode.Query` + `_checkQueriesForRollback` rides the B5/B9 rollback-and-finalization migration. See PROGRESS_LOG.
- [~] **B5.** Hash window broadcasts + uncertainty-aware desync detection (2026-05-29). Core landed: `HashWindow.js` — `HashWindowBuilder` (positional `stateHashes[]` on an `interval` checkpoint grid + `usedInputs[]` with confirmed flags) producing `{oldestTick, interval, stateHashes, usedInputs}`; `compareHashWindows(local, remote)` → agree / wait / desync / incomparable, where a mismatch is only a desync when neither peer has an unconfirmed relevant input in `(lastAgreed, diverge]` (bounded-time flip wait→desync once inputs confirm). Decisionless about recovery. + `test-harness/HashWindowNode.js` (genuine cross-peer window exchange). Adds TEST_SCENARIOS categories 10 (S-010-01/02), 11 (S-011-01/02/03), 12 (S-012-01/02/03). Tests: hash-window.test.js (16 unit), hash-window-harness.test.js (4) + selftest-b5.mjs (4, run on Node 12 too). **Remaining for [x]:** wiring into v1's eager per-tick `ReceiveStateHash`/`CheckStateHashes` rides the B6/B9 windows-and-finalization migration. See PROGRESS_LOG.
- [~] **B6.** Tunable acceptance + grace windows (2026-05-29). Core landed: `AcceptanceWindows.js` — frozen/validated `WindowConfig` (`acceptanceWindowMs`/`graceWindowMs`/`snapshotIntervalTicks`/`attendanceIntervalMs`, defaults 200/300/20/500) + pure `classifyInput({nowMs,inputMs,relayed})` → `accept` / `reject-raw-in-grace` / `reject-beyond-grace` (inclusive boundaries; floor-based `windowTicks` conversion) + `test-harness/AcceptanceNode.js`. Raw-vs-relayed grace asymmetry proven; genuine cross-peer relay convergence (laggard partitioned from origin reconverges via a relay into its grace window). Tests: acceptance-windows.test.js (22 unit, incl. ±1ms edges), acceptance-windows-harness.test.js (5) + selftest-b6.mjs (5, run on Node 12 too). Adds TEST_SCENARIOS categories 8/9. **Remaining for [x]:** wiring the windows into v1's implicit accept/reject + finalization rides the B8/B9 recovery-and-finalization rework. See PROGRESS_LOG.
- [~] **B7.** `queryDisconnected` + disconnect-as-simulation-event (2026-05-29). Core landed: `DisconnectTracker.js` (pure) — canonical disconnect tick = `lastAttendanceTick + timeoutTicks` (deterministic from the shared tick-stamp, NOT local detection time — resolves S-005-06 / KNOWN_ISSUES #8); monotonic forward-only (stale/reordered beats ignored); a late newer beat shifts the tick forward, retroactively un-disconnecting `[old, new)` and reporting `earliestAffectedTick` so a disconnect-conditional rollback restarts at `old` — only when the peer already simulated past `old`. Decisionless (gating=B6, recovery=B8). + `test-harness/DisconnectNode.js` (self-beating + LOCALLY-injected `beats` for precise retroactive-rollback timing; NO attendance forwarding — see below). Resolves the LOCAL half of S-005-06; adds TEST_SCENARIOS categories 13 (disconnect as simulation event) + 14 (disconnect agreement under partition). Tests: disconnect-tracker.test.js (12 unit), disconnect-tracker-harness.test.js (4) + selftest-b7.mjs (4, run on Node 12 too). **Full suite: 227 vitest pass + harness green.** **Design correction (2026-05-29):** an earlier attendance-RELAY in DisconnectNode was REMOVED — proactive forwarding violates the sparseness constraint and re-introduced the "special protocol" the now-retired DECISIONS #17 forbade. Sparse cross-network convergence is instead DECISIONS #30 (pull-on-suspicion probe fast path + B5-desync/B8-recovery fallback). **Remaining for [x]:** implement the #30 probe/convergence; wire `queryDisconnected` + the disconnect-conditional rollback into the v1 engine (rides B8/B9). See PROGRESS_LOG.
- [~] **B7.1. ~~Sparse disconnect-convergence PROBE — FAST PATH~~ SUPERSEDED + DELETED (2026-06-05).** Replaced by beat FORWARDING (grow-only-max gossip); `DisconnectProbe.js` + `ProbeNode.js` + `disconnect-probe*.test.js` + `engine-l3-probe.test.js` + `selftest-b7.1.mjs` removed and the engine unwired (the B5→B8 fallback is retained as the backstop). Why: a 2-trip probe over the same reliable transport can never rescue a case 1-trip reliable gossip can't, and being one-shot per `(playerId, tickY)` a single dropped correction caused a false disconnect. See DECISIONS #30 + `DESIGN_PARTICIPATION.md` §6.2. ORIGINAL (2026-05-29, for history): Pure `DisconnectProbe.js` over `DisconnectTracker`: `suspicions(currentTick, relevant)` emits a deduped `{playerId, tickY}` only when imminent (`currentTick ∈ [Y-probeLeadTicks, Y)`) AND relevant; `onSuspicion` replies with our beat iff strictly newer + not already-observed (amplification backoff); `onCorrection` applies grow-only-max + records observed; re-emits after a correction shifts Y. + `test-harness/ProbeNode.js` (self-beat + `initialBeats` for divergent start state; broadcasts suspicion, replies correction). Adds TEST_SCENARIOS category 18. Tests: disconnect-probe.test.js (24 unit), disconnect-probe-harness.test.js (4) + selftest-b7.1.mjs (4, Node 12 too) — fast-path convergence (no rollback), relevance-gate control, amplification suppression, NO spurious un-disconnect. **Full suite: 291 vitest pass + harness green.** SLOW-path fallback (B5-desync → B8 recovery carrying last-attendance-tick) owned by B8 (`mergeLastAttendanceTicks`). In-engine wiring rides B9. See PROGRESS_LOG.
- [~] **B8.** Authority + severe-desync recovery + lagging-peer wake (2026-05-29). Core landed: pure `Recovery.js` — `compareAuthority`/`isAuthoritative` (deterministic total order: older sim wins, lower id breaks ties), `resolveDesync` (loser adopts / winner serves; adopting also adopts the winner's provenance so a connected component converges monotonically to one history), `shouldResetSimulationAge`/`resetSimulationAge` (lagging-peer wake — a far-behind peer drops to age 0 so it yields instead of dominating), `makeStateTransfer`/`validateStateTransfer` (opaque snapshot by reference + per-participant `lastAttendanceTicks`) and `mergeLastAttendanceTicks` grow-only-max = the DECISIONS #30 SLOW-PATH FALLBACK. + `test-harness/RecoveryNode.js`. Adds TEST_SCENARIOS categories 15 (severe desync recovery), 16 (lagging peer wake), 17 (authority tie-break). Tests: recovery.test.js (32 unit), recovery-harness.test.js (4) + selftest-b8.mjs (4, run on Node 12 too) — incl. a disabled-reset CONTROL proving the lag reset is load-bearing. **Full suite: 263 vitest pass + harness green.** **Remaining for [x]:** wiring authority + the state-challenge/transfer flow into the v1 engine (no authority model there today) rides B9 finalization. The #30 fast-path probe is the separate **B7.1**. See PROGRESS_LOG.
- [x] **B9.** Tick finalization + memory bounding (2026-05-29). Pure `Finalization.js`: `TickFinalizer` tracks the finalization horizon as GROW-ONLY-MAX (`maxCurrentTick - graceWindowTicks`) so a recovery backward tick-jump can never un-finalize already-collected ticks — ticks strictly older than the horizon are immutable; two payload-agnostic tick-only GC policies — `collectAnchored` (carry-forward collections — snapshots, last-input-per-participant: keep the latest entry ≤ horizon as the re-sim anchor + everything after) and `collectBelow` (no-carry-forward logs — query logs: drop everything strictly below the horizon). + `test-harness/FinalizationNode.js` (accumulates snapshots/query-logs/sparse-inputs per tick, runs the GC each tick, `gcEnabled` toggle for the control). Adds TEST_SCENARIOS category 20. Tests: finalization.test.js (21 unit), finalization-harness.test.js (4) + selftest-b9.mjs (4, Node 12 too) — bounded-growth plateau (retention identical at tick 600 vs 1200), GC-disabled CONTROL (grows unbounded — proves the GC is load-bearing), rollback-anchor invariant, sparse carry-forward (change-once-then-silent participant keeps exactly its last tick). **Full suite: 316 vitest pass + harness green.** In-engine wiring (the actual release of retained per-tick data) rides B-Integrate. See PROGRESS_LOG.
- [x] **B10.** Random-peer bootstrap selection + catching-up state (2026-05-29). Pure `Bootstrap.js`: `eligibleServers` (LIVE-only filter), `selectServingPeer(candidates, rng)` (joiner-side uniform pick, clamped index, injected seeded rng → uniform serving-peer histogram, no load sink), `makeBootstrapPayload`/`validateBootstrapPayload` (DECISIONS #18 three-piece payload: snapshot @ grace-window edge by reference + since-edge sparse input log [strictly-after-edge enforced] + per-participant baseline AT the edge; frozen wrapper), `reconstructInputs` (per-participant `SparseInputDecoder`, baseline-seeded, log-only=null passive → re-sim reproduces every tick ≥ edge via hold-last), `CatchUpTracker` (monotonic CATCHING_UP→LIVE, `noteProgress` flips once within tolerance, Layer-3-visible). + `test-harness/BootstrapNode.js` (server/joiner roles; joiner picks one server, sends one reliable `boot-req`, adopts the `boot` payload, re-simulates edge→present). Adds TEST_SCENARIOS category 21. Tests: bootstrap.test.js (21 unit), bootstrap-harness.test.js (4) + selftest-b10.mjs (4, Node 12 too) — uniform serving-peer distribution over 100 joins (chi² < 13.28 df=4), sparse single-server contact, catching-up lifecycle (one enter/leave pair, ends LIVE, adopts state @ captured present), re-sim fidelity (baseline held + since-edge changes at right ticks; never-heard=null). **Full suite: 341 vitest pass + harness green.** In-engine wiring (when to request, the real re-sim loop) rides B-Integrate. See PROGRESS_LOG.
- [x] **B-Integrate.** Big-bang assembly of the v2 engine from the Phase B cores (DECISIONS #32). After B9+B10, before Phase C. Gating first step: cut the wall-clock/rAF coupling so `EasyMultiplayer` takes an injected clock + manual tick driver (KNOWN_ISSUES #7). Absorbs every "in-engine wiring rides B9" deferral from B1–B8/B7.1. **DONE 2026-05-29:** `SimulationEngine.js` (new module, not a v1 retrofit — DECISIONS #32) composes all ten cores over injected Transport + clock with a manual tick; built across five test-gated layers (L1 sparse-input, L2 sim/rollback/query-freeze/hash, L3 acceptance/disconnect/probe/recovery, L4 finalization-GC/bootstrap, L5 end-to-end). `tests/engine-l1..l4` + `tests/engine-integration.test.js` + Node-12 `selftest-b-integrate.mjs`. Full suite 373/373 + all harness selftests green (Node 20 & 12). Deferred to Phase C: real-transport bootstrap gaps (KNOWN_ISSUES #8), `usedInputs`/"wait"-tier recovery wiring + shipped `lagThresholdTicks`/`probeLeadTicks` defaults (KNOWN_ISSUES #4).

## Phase C — Polish & Scale Validation

- [x] **C1.** Re-implement Trystero transport behind the new `Transport` interface — DONE 2026-05-29. DI binding (`{ joinRoom, selfId }`) injected; async `createTrysteroTransport()` factory isolates the remote URL; pre-connect-throw / unknown-peer-no-op / reliable-channel gaps fixed; full 12-point conformance runs against the real class over `FakeTrysteroNetwork` (Vitest 17 + Node-12 selftest 16). Real-WebRTC subset rides C4.
- [~] **C2.** Migrate pacman / graph-pacman examples to v2 API — facade rewire DONE 2026-05-29. **C2.1** `RealtimeClock.js` (production clock adapter over setTimeout; 11 tests). **C2.2** `EasyMultiplayer.js` re-pointed off the v1 SyncedScene/RollbackNetcode stack onto `SimulationEngine`, with the four game-facing services the engine lacks ported as a thin Node-testable layer: `SeededRandom.js` (pure (seed,tick,counter) RNG, rollback-safe + cross-peer-deterministic, 8 tests), `EventSystem` bracketed by a new optional `onRollback({phase,fromTick})` seam on the engine (3 tests), `PresentationHints` (per-tick setTickTime), deterministic in-step `playerJoined` / transport-liveness `playerLeft`. The public API (`defineInput`, `getLocalInputs`, both `query` forms, `manageState`, `on/onEvent`) is unchanged, so BOTH example HTML files need NO source edits. Headless 2-peer verification `tests/easy-multiplayer-integration.test.js` (5 tests): convergence under rollback-inducing latency, RNG-state convergence, deterministic join on every peer, event timeline convergence + over-prediction cancel, AND the exact example API paths (defineInput + both query forms + manageState). Full suite 417/417 + Node-12 harness green. REMAINING (browser-only, human): pixel rendering, real WebRTC, DOM-keyboard, three.js/audio — rides C4.
- [x] **C3.** Scale tests: 1 active + 100 passive on `MemoryTransport` — DONE 2026-05-30. `test-harness/ScaleScenario.js` runs N passive + 1 active SimulationEngine peers on the deterministic MemoryTransport and emits per-peer metrics (per-WIRE-TYPE send volume via transport broadcast/send wrapping, retained-snapshot/query-log memory, rollback count). All three GOALS success bullets verified as falsifiable assertions (`tests/scale-scenario.test.js` 5 tests + Node-12 `selftest-c3.mjs` 4): passive peers send ONLY attendance (MSG_INPUT=0); active rollbackCount is N-independent (=0 at N=10 and N=100 — steady beats push the canonical disconnect tick into the future, never crossing a simulated tick); passive memory bounded over TIME (snapshots/queryLog plateau at 7 across ticks=100/200, GC load-bearing) and over N (input-decoder participants stays 1 — passives never accumulate per-passive-peer input state; O(N) cost confined to the inherent liveness tracker). SCOPE: latency-0 shared-clock substrate — structural scaling, not real-WebRTC perf (rides C4).
- [ ] **C4.** Browser-based multi-instance test page driving scenarios against the real transport
- [ ] **C5.** Determinism enforcement (Math.random / Date.now / iteration order)
- [ ] **C6.** **Bus-based rollback** subsystem (separate displayed state from authoritative state; parallel / sliced workers)

## Cross-Cutting (Meta)

- [ ] **M1.** Documentation stays current — each Phase B goal completion updates `ARCHITECTURE.md`, `DECISIONS.md`, `PROGRESS_LOG.md`
- [ ] **M2.** Scenario catalog stays comprehensive — each Phase B goal adds the scenarios that drove it
- [ ] **M3.** Public API stable after Phase B — versioned; breaking changes called out

## Out of Scope (this phase)

- Dynamic interest areas / spatial relevance (future research per the design doc)
- Cryptographic input validation
- Server-authoritative transport (interface accommodates it; implementation deferred)

---

## Legacy / Pre-Redesign Tasks (deprecated)

The Phase 0–5 tasks from the prior plan (2026-03-12) are superseded by the structure above. Code-quality items (global state, hardcoded CDNs, etc.) carry over into `KNOWN_ISSUES.md` and will be addressed opportunistically during Phase B refactors.
