← Workstreams

Workstream: AForce2 Multiplayer

Status: Planned · Component: Maximize developer productivity

AForce2 is a game, and this workstream plans its multiplayer mode — but it earns its place in the plan as the ecosystem's most demanding dogfooding vehicle for the url:// stack: a real-time, latency-sensitive, P2P-announced workload spanning the full standard layered architecture and the full deployment spectrum (self-hosted in-game server, dedicated CLI server, hosted service). No other consumer exercises UrlProtocol/UrlResolver under interactive-latency pressure.

North star

Two people on different machines each command a squadron of identical-looking ships, hunt for the one ship the other human is secretly flying, and the last human flagship standing wins.

The mode is hidden-flagship squadron combat:

  • Each map defines a set of teams; each team is an equal-size squadron of ships with map-authored starting positions. The number of teams a map defines is the number of players that can join a match on it.
  • A joining player takes over a free team. One ship in the squadron, selected at random at spawn, becomes the player's flagship; the rest are AI wingmen. All ships on a team look identical — opponents cannot tell which ship is human.
  • Destroying a player's flagship eliminates that player. The eliminated player's remaining wingmen are removed from the field, and the eliminated player stays connected as a viewer. When only one player's flagship survives, that player wins and the session ends with a recorded winner. Simultaneous destruction of the last two flagships is a draw.
  • Matches are server-authoritative: clients submit commands and render server snapshots; the server owns every ship's position and trajectory.

Game-design decisions (settled 2026-08-03)

These were decided explicitly with the product owner and are not open for re-litigation by implementers; changes go back through this document.

  1. Match end — last flagship standing wins; the session transitions to ENDED carrying winner and endReason; draw on simultaneous destruction of the final two flagships.
  2. Squadrons come from maps, not settings — each map's multiplayer unit file determines squadron size, spawn positions, and team count (and therefore that map's max players). Equal squadron sizes per team are a validated invariant of the map format.
  3. Uniform one-life death model — in squadron mode every ship, flagship and wingman alike, dies permanently at zero health. The current 3-lives/health-restore model is retired here because any death-behavior difference between flagship and wingmen leaks the flagship's identity (a ship observed surviving a kill must be the human).
  4. Hidden identity is a server-enforced information boundary — which ship is the flagship is knowable only to the owning player's client. This is a snapshot-filtering requirement, not a UI convention (see design §2).
  5. Disconnects use unattended-keyboard semantics — a disconnected player's flagship simply continues in its current direction until it hits a barrier, exactly as if the player were connected but away from the keyboard. No autopilot takeover, no pause. On reconnect (same token) the player issues the next command. A player absent past the session's idle-prune window forfeits: eliminated as if their flagship had been destroyed.
  6. Latency model — the server is the source of truth for every ship; ships are assumed to continue on their path unless a command tells the server otherwise. The server accepts up to 100 ms of latency on an incoming command by rewriting history as-if the command had arrived up to 100 ms earlier (covering keystroke transit time). Server updates of position/trajectory are authoritative: after a drop or latency spike, the client corrects its rendered positions to the server's state on recovery.

Current state — built vs. broken

Multiplayer already exists as a five-repository stack and is playable today as a server-authoritative free-for-all prototype:

Repository What it provides today
AForce2 The game; headless multiplayer engine (com.aforce2.multiplayer, a GameEngine SPI implementation) and the in-game host/join client (--host / --join url://…) with a 30 fps Compose window
AForce2GameApi The GameEngine SPI and client-facing service API: sessions, tokened players, nine wire actions, versioned snapshots
AForce2GameEmbedded The session manager: join/leave/end lifecycle, UUID-token auth, idle pruning, lazy poll-driven simulation advance
AForce2GameCli Admin/hosting CLI (serve, create, sessions, end, health)
AForce2GameServiceServer The url:// hosting layer: RPC marshaling, P2P announcement with reannounce, served client bytecode

What works end-to-end today: session create/join/leave/end, tokened players, TURN/SHOOT/FIRE_LASER/PLACE_MINE/WARP/STOP/REVERSE over the wire, versioned change-cursor snapshots, team scoring, optional map AI, eight maps, up to 7 players, in-game hosting that prints a joinable url://aforce2-lan-…/ address, dedicated serving, and P2P announcement.

Verified defects and structural gaps this workstream must resolve (all confirmed by direct source inspection, 2026-08-03):

  • Time bombs never detonate in multiplayer. The multiplayer advanceTo() skips the updateTimedObjects() call the single-player loop makes, and TimeBomb is not a movable object, so its fuse never runs (AForce2/src/com/aforce2/multiplayer/AForce2HeadlessGameEngine.kt vs AForce2/src/com/aforce2/AForce2.kt).
  • Remote detonators cannot be detonated. There is a place action but no trigger action in the wire enum, no trigger method on RemoteDetonator, and ship collision handling covers Mine but not RemoteDetonator — the object is scenery.
  • No match ever concludes. A defeated player is a dead end (defeated=true, actions ignored) and the session stays ACTIVE until everyone leaves or goes idle; there is no winner, end reason, or last-standing check.
  • The snapshot leaks exactly what hidden-flagship must hide. PlayerSnapshot.shipObjectId (plus per-player health/lives) is broadcast identically to every client, and each player slot gets a distinct sprite — today an opponent's client can trivially identify every human ship.
  • No reconnection. Idle pruning invalidates the token; the client gives up after ten consecutive frame failures; recovery means joining as a new player.
  • Player spawning ignores maps — hard-coded bottom-row spawn points, hard-coded teams 1/3–8, per-slot sprites. All of this is superseded by the map-driven squadron model.
  • One engine per JVM — the engine state is process-wide (AForce2GameEngineFactory enforces a single concurrent engine), so a dedicated server hosts one match at a time. Accepted for this workstream; multi-match servers require de-staticizing core game state and are explicitly out of scope.

Test coverage today

Assessed against the Testing Architecture standard (2026-08-03): the suite is solid at the component level — 14 headless-engine tests in AForce2 (real cross-player bullet damage, defeat scoring, capacity, maps), four strong session-manager test files in AForce2GameEmbedded (frame exchange, lifecycle, pruning, validation), and a direct-dispatch RPC-handler test in AForce2GameServiceServer — but no test in any repository crosses a real url:// transport (no test imports UrlResolver/UrlProtocol2 at all), no test runs two clients against one server, the production client/window path is nearly untested, and warp/reverse/stop/time-bomb/remote-detonator have no behavioral coverage through the multiplayer path. The two broken weapons coincide exactly with the untested actions — the coverage gap is how the breakage went unnoticed. "Is multiplayer extensively tested e2e?" — no; the advertised experience (two machines playing over url://) has never been exercised by a test.

Design

1. Multiplayer map format: Units-Multi.xml

A sibling of each map's Units-Single.xml, in the same XML dialect (UnitTypes + Units with team, xlocation, ylocation, direction). A map is multiplayer-capable if and only if the file exists; all eight shipped maps get one authored as part of this workstream.

  • One UnitType (one sprite) per team — squadron uniformity is what makes wingmen indistinguishable; the loader rejects a team with mixed sprites.
  • Equal squadron sizes across teams — validated at load; unequal counts are a map error with a descriptive message.
  • Max players = number of teams in the file; the session's maxPlayers is clamped to it.
  • Squadron activation at join — an unclaimed team's ships are not on the field; joining spawns the whole squadron at its authored positions and randomly designates the flagship. Random flagship selection is runtime behavior, deliberately not map data.
  • Neutral AI retires in squadron mode — the wingmen are the AI; includeAiUnits and the reserved AI team disappear from this mode. (A future playable="false" team attribute could reintroduce hostile AI factions; not in this workstream.)

2. Per-viewer snapshot filtering

Hidden identity is enforced where the authority lives — the server. The snapshot pipeline becomes viewer-aware:

  • A player's own frame exchange includes their flagship's object id (they must know which ship they fly). Snapshots delivered to anyone else carry no flagship-identifying information: no shipObjectId, no per-ship health/lives attribution to a player, no field whose value differs between flagship and wingman.
  • Wingmen and flagship share sprite, team, and death behavior (design decision 3), so the object list itself is identity-neutral by construction; the filtering requirement is about the player-state section of the snapshot.
  • Eliminated players and spectators receive the opponent-filtered view while the match runs. When the match ends, the final snapshot may reveal flagship identities (the reveal is part of the payoff).
  • This is a tested invariant, not a convention: an e2e test asserts that the byte-visible snapshot stream an opponent receives contains nothing that distinguishes the flagship (see Testing).

3. Match lifecycle

The engine gains a terminal condition: after each advance, if at most one non-eliminated player remains (flagship destroyed ⇒ eliminated; forfeit ⇒ eliminated), the session ends. GameStateSnapshot gains winner (player id, absent on draw) and endReason (LAST_FLAGSHIP_STANDING, DRAW, ENDED_BY_PARTICIPANT, ALL_PLAYERS_LEFT). Eliminated players' frame exchanges keep working (viewer mode) so they can watch the match conclude; their actions remain ignored.

4. Disconnection, reconnection, and forfeit

  • The join token remains valid within the session's idle-prune window; the client retries exchangeFrame with backoff instead of shutting down after ten failures.
  • While disconnected, the flagship flies on unattended (decision 5) — no state change on the server at all; a disconnect is indistinguishable from an idle player, which is itself a small identity-protection property.
  • Pruning = forfeit = elimination: squadron removed, match-end check runs, a lone surviving player wins by walkover.

5. Latency compensation (100 ms command backdating)

Per decision 6. Implementation shape (implementer judgment, reviewed here):

  • Commands acquire a server-assigned arrival time; the server estimates per-client one-way latency and backdates each command by that estimate, clamped to 100 ms. Client-supplied timestamps are never trusted beyond the clamp.
  • Backdating rewinds only the issuing ship's kinematics (analytically computable — motion is linear until a barrier), applies the command, and re-advances to now with collision checks along the corrected path. There is no retroactive un-hitting: collisions that already resolved stay resolved. Full rollback-replay of the world for a 100 ms window would let already-rendered kills un-happen — a worse artifact than the one it fixes.
  • Clients render authoritative snapshots and may extrapolate ships along their stated trajectories between updates ("continue on path" is the engine's actual physics), snapping to server state on the next authoritative update.

6. Simulation drive

The lazy, poll-driven advance stays: any player's frame exchange advances the shared match clock (capped at 1 s per call), an unpolled match is effectively paused, and dedicated servers carry zero idle cost. ManualClock keeps every test deterministic. A background fixed tick is deliberately not added in this workstream; revisit only if a future mode needs simulation to progress with zero connected clients.

Testing plan

The testing pillar closes the gap the assessment found — coverage must reach the layers where "multiplayer" actually happens:

  1. Flagship e2e scenario (the gold standard, Milestone 1): a real url:// server (isolated UrlProtocol2(bootstrapPeers = emptyList()) per the TESTING.md pattern) with two independent real clients; both join, one player's bullet destroys the other's flagship; both clients observe the same authoritative sequence — damage, elimination, match end, winner.
  2. Hidden-identity invariant test: through the same two-client setup, capture every snapshot an opponent receives across a full match and assert none contains flagship-identifying information (no ship object id attribution, no distinguishing field, identical sprites). This test is the enforcement mechanism for design §2.
  3. Map-format validation tests: unequal squadrons, mixed sprites per team, missing Units-Multi.xml, team-count/max-player clamping — all with full-text error-message assertions.
  4. Weapon-parity tests through the multiplayer path: time-bomb fuse and blast, remote-detonator place-and-trigger, warp, reverse, stop — each asserted via engine and via the networked path, so action coverage matches the public surface.
  5. Reconnect tests: token-based resume within the prune window (ship kept flying straight meanwhile), forfeit-by-prune elimination, client retry behavior under a dropped transport.
  6. Backdating determinism tests (Milestone 2): ManualClock-driven scenarios asserting a command backdated by exactly N ms (N ≤ 100) produces the analytically expected corrected position, including the barrier-interaction and no-retroactive-collision cases.
  7. Concurrency tests: many genuine concurrent frame exchanges against one session (the honest amplifier), asserting version monotonicity and no lost commands.

Milestones

Milestone 1 — the mode works

Map schema + loader validation; map-driven squadron spawn with random flagship; per-viewer snapshot filtering; uniform one-life death model; last-flagship-standing match end with winner/endReason; fix the two broken weapons (time-bomb fuse wired into multiplayer advance; DETONATE wire action + collision handling for remote detonators); token-based reconnect with forfeit-on-prune; testing-plan items 1–5; README/docs updated to describe the real mode.

Milestone 2 — it feels good

100 ms command backdating with server latency estimation; client trajectory extrapolation + authoritative correction; testing-plan items 6–7; multiplayer window/HUD tests; match-result surfacing (winner screen, per-player stats); optional: an AUTOPILOT wire action so a flagship can deliberately blend in by flying like a wingman.

Later / explicitly out of scope

Chat; server browser and discovery UX (paste-a-URL stays); lobby/ready/rematch flows; an explicit spectator role with its own lifecycle; a playable CLI client; host migration; shared pause; multi-match dedicated servers (requires de-staticizing engine state); hostile neutral-AI factions (playable="false" teams).

Open questions

  • endSession authority — today any participant token can end a match. Keep, or restrict to the session creator? (Leaning: restrict to creator once sessions have an owner; low urgency while matches end naturally.)
  • Spectator liveness — unauthenticated getGameState polling does not refresh anyone's liveness, so a match with only viewers still idle-prunes its players. Intended? (Leaning: yes — viewers should never keep a match alive.)
  • Scoring display in squadron mode — team scores still accumulate (wingman kills included) but the win condition ignores them. Keep as a secondary HUD stat, or hide? (Leaning: keep; it is flavor and feedback.)
  • Post-elimination reveal — in 3+ player matches, is an eliminated player's flagship identity revealed to the survivors at the moment of elimination, or only at match end? (Leaning: revealed at elimination — the kill deserves its payoff — but this is a product call.)