Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
id: decision-034
title: Two-player core semantics -- shared status, per-player alive flag, cross-player collision, mutual kill
date: '2026-09-13 21:57'
status: Accepted
---
## Context

TASK-051 reads as "nearly free, Godot-layer only" ("Add local 2-player support to the Godot
presentation and input layers"), but `core/world.zig` had no `player_count` and no per-player
state at all before this task -- `MAX_SUPPORTED_PLAYERS` in `core/abi.zig` was hardcoded to `1`,
and `World` was a single flat struct. Godot's `world.gd` (TASK-027) already forwards `player`/
`player_count` params through every method to the GDExtension class, and `include/neo_snake.h`'s
`ns_input`/`ns_player_view`/`ns_config.player_count` are already N-player-shaped -- but nothing on
the `core/` side backs any of it for `player_count > 1`. Real two-player local play needs that
support built first.

`decision-020` (TASK-021) explicitly left cross-player collision "genuinely undecided," scoped its
own two-player primitive to `core/fuzz_seeds.zig` only, and said real PvP rules are "left entirely
to whatever TASK-053/m-8 designs" -- deliberately not constraining this decision. `reference/
snake.html` is single-player only and has no oracle behavior to match here; every choice below is a
new design, not a parity check.

## Decision

**`MAX_PLAYERS: u8 = 2`** (in `core/world.zig`, reused by `core/abi.zig`'s
`MAX_SUPPORTED_PLAYERS`) -- capped at exactly 2, not N, matching this task's actual AC ("two
locally-controlled players") and `decision-020`'s own `MULTI_PLAYERS=2` precedent. YAGNI: nothing
in this repo needs more than 2 yet.

**Shared world-level `status` (menu/playing/paused/dead) drives the overall game phase.** Pausing
pauses both players; either player's first direction input (from menu or dead) starts or restarts
the whole shared match for both -- `start()`/`reset()` stay whole-world operations, extending
`decision-015`'s existing single-player "press any direction to begin/restart" quirk to a
same-screen match's natural convention.

**Per-player `alive: bool`, not a full independent `Status` enum.** The per-player status exposed
through the ABI (`ns_player_view_get`, HUD-facing) is a single projection: `if (alive) w.status
else .dead` (`playerStatus` in `core/abi.zig`). A dead player's board keeps rendering as "dead"
even while the match continues for a survivor, without needing two parallel state machines.

**Starting position: `cy(i) = rows * (i+1) / (player_count+1)`** (integer division), spacing
`player_count` players evenly down the board. This is a strict generalization of the oracle's own
`rows/2` formula: at `player_count == 1` it reduces to `rows*1/2 == rows/2` exactly, so
single-player starting position is unchanged byte-for-byte.

**Cross-player collision, two-phase (extending `decision-020`'s phase split):**
- Phase 1 (read-only, order-independent): for each player, compute the candidate new head cell
and check it against (a) walls/self-body, exactly as single-player already does, and (b) every
*other* player's full pre-tick body -- with **no tail-vacate exception** granted to opponents.
An opponent's tail cell is still occupied as far as this tick's move is concerned, since that
opponent's own tick hasn't resolved yet. This is the simplest, most conservative reading
available and avoids having to define a cross-player analog of the single-player tail-vacate
rule that decision-020 never touched.
- Phase 2 (fixed ascending player-index order): mutate bodies, apply deaths, resolve eating/food
respawn -- same ascending-index rule `decision-020` and `docs/abi-decisions.md` already require
for the shared RNG stream and food placement.

**Head-to-head mutual kill:** if two surviving players' computed new-head cells coincide, both
die. Symmetric outcome, no arbitrary tie-break by player index -- a same-screen match where two
snakes collide head-on is expected to be a draw for both, not a coin-flip favoring one player.

**Win uses the same "win == dead" sentinel convention as single-player**, per player -- no separate
"won" state was invented.

**A dead player's body is never cleared -- a permanent corpse/obstacle**, exactly matching
single-player's existing behavior of never clearing the body on death. This is a deliberate
divergence from `fuzz_seeds.zig`'s non-canonical `MultiPlayer`/`placeFoodShared` primitive (which
skips dead players when checking occupancy) -- legitimate since `decision-020` explicitly said that
primitive's scope does not anticipate or constrain this design. Corpses remain solid obstacles for
the survivor.

**World-level `tick` increments only if at least one player is alive after the tick resolves** --
an exact generalization of single-player's existing "die()/win() return before `w.tick += 1`"
quirk; at `player_count == 1` it reduces to the original behavior exactly.

**World-level `status` becomes `.dead` only when every player is simultaneously eliminated.**

**Tick period/speed is read from player 0's score only**, not per-player -- a shared-screen match
shares one clock either way, so there is no meaningful per-player speed to derive.

**Wire format:** `canon.decode()`'s cell packing is contiguous/bump-allocator-style (every player's
cells packed back-to-back from offset 0, sized by each player's actual `body_len`), which does not
match `World`'s new fixed-per-player-offset `cells_buf` layout once `player_count > 1`.
`ns_deserialize` reconstructs a flat scratch region (reusing `players[0]`'s own backing buffer,
sized `per_player * player_count`), decodes into it, then relocates each player's decoded cells
into its real per-player buffer via `@memmove` -- safe regardless of processing order, since each
player's real destination offset can never overlap an unread portion of the source range when every
`body_len <= per_player`.

**`ns_world_init` rejects `player_count > 1` when `rows <= 8`** (`NS_ERR_INVALID_ARGUMENT`) --
guards against a board too short for two players' 3-cell starting snakes to avoid immediately
overlapping under the `cy(i)` spacing formula at small row counts.

## Consequences

`core/world.zig`, `core/abi.zig`, and `core/fuzz_seeds.zig` all changed to carry a `player_count`
dimension end to end. Every existing single-player test (Tier-A `core/build.zig test`, Tier-B
`difftest`, Tier-C `abitest`) passes unchanged -- confirming `player_count == 1` behavior is
byte-for-byte preserved under every one of the generalizations above. A new two-player test suite
in `core/world.zig` (starting-row spacing, wall-death-doesn't-stop-survivor, all-players-eliminated
stops the tick counter, opponent-body-collision-is-fatal, head-to-head mutual kill) and a new
`core/abitest.zig` conformance test (two independently-controlled players driven purely through the
C ABI) exercise the new behavior directly. `fuzz_seeds.zig`'s own private `MultiPlayer` primitive
(`decision-020`) is left untouched -- it remains a narrower, non-canonical permutation-invariance
fixture, not a rehearsal of this design, and continues to diverge from it exactly where documented
above (no dead-player occupancy skip). Any future lockstep/netcode work (TASK-053, m-8) inherits
this collision/status model as the real one, rather than re-deciding it from scratch.
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
id: decision-035
title: Godot-layer local 2-player -- keyboard split, ABI-free shared-status derivation, player-0-scoped best score
date: '2026-09-13 22:13'
status: Accepted
---
## Context

`decision-034` settled `core/world.zig`/`core/abi.zig`'s 2-player semantics. TASK-051's remaining
scope is the Godot presentation/input layers, and its AC#1 is a hard constraint: this task must not
modify `include/neo_snake.h`. Three judgment calls came up wiring `game/` up to the now-real
2-player ABI, none of them dictated by the task text or by `decision-034`.

## Decision

**GameScreen always initializes `player_count = 2`**, not a togglable 1p/2p mode. Extending the
existing menu's mode selector (`game/content/modes.json`) to also pick a player count would require
cross-validating against `tuning.json`'s `scoring.best_score_defaults` (`game/content/loader.gd`'s
`MODES_SCHEMA`) for no benefit this task's AC actually asks for -- YAGNI.

**Keyboard scheme split: player 0 keeps arrow keys only, player 1 gets WASD only**
(`game/platform/input_defaults.gd`'s new `ACTION_P2_MOVE_*` actions, `game/project.godot`'s
`p2_move_*` bindings). Player 0's `move_up`/`move_down`/`move_left`/`move_right` action *names* are
unchanged (save-data/keybind-codec compatibility), but their physical keycodes drop WASD -- keeping
WASD on both player 0's and player 1's actions would fire both players' movement from one keypress,
a real bug, not a style preference. Touch swipe and joypad analog stick stay routed to player 0 only
(`input_router.gd`'s `_handle_drag`/`_handle_joypad_motion` hardcode player 0) -- no natural
per-player mapping exists for either input kind on one shared keyboard/no second controller, so this
is a deliberate scope decision, not an oversight.

**`GameScreen._shared_status()` reconstructs the true shared world status from only per-player ABI
calls**, since AC#1 forbids adding any new header/ABI function to expose the shared status directly:

func _shared_status() -> int:
var v0: int = world.player_view_get(0).status
if v0 != BoardGeometry.STATUS_DEAD:
return v0
return world.player_view_get(1).status

Correct because `core/abi.zig`'s `playerStatus` projects `w.status` onto a still-alive player
unchanged, and (per `decision-034`) the shared world only becomes `.dead` once every player is
simultaneously eliminated. So checking player 0 first and falling back to player 1 only when player
0's own projection already reads DEAD always yields the true shared phase, for every alive/dead
combination. Used for both shared decisions (pause/restart/overlay gating, the overlay's own
screen) and, separately, each player's own raw `player_view_get(i).status` still drives that
player's own HUD status label (`hud.update`/`hud.update_p2`) -- a dead player's board and status
text keep reading "dead" even while the match continues for a survivor, matching `decision-034`'s
per-player `alive` semantics.

**Best-score persistence stays player-0/mode-scoped only.** Player 1's score renders live in the HUD
(`Hud.update_p2`) but is never compared against or written to `_save_data.best_scores` -- the
existing best-score architecture is inherently single-value-per-mode, and extending it to per-player
bests is out of this task's scope.

**`BoardView` needed zero code changes.** It was already generic over `player`
(`setup(world, tuning, palette, p_player: int = 0)`, every draw call already parameterized). 2-player
rendering is a second `BoardView` instance (`board_view_p2`) at the same position/size, sharing the
same `SimulationWorld`, independently redrawing player 1's snake on top of the (harmlessly
redundantly redrawn) shared board/food/grid.

**Per-player death/eat fx cues route via the event dict's existing `"player"` field**
(`core/abi.zig`'s `pushEvent` already tags every event with its player index) -- `GameScreen._process`
picks `board_view` or `board_view_p2` per drained event before calling `.notify_eat()`/
`.fx.trigger_flash()`, so player 1's death flash doesn't fire on player 0's board and vice versa.
Coalesced sfx cues (`AudioEventCoalescer`) stay global/shared, matching a same-screen match sharing
one speaker.

## Consequences

`game/platform/input_defaults.gd`, `game/project.godot`, `game/platform/input_router.gd`,
`game/presentation/screens/hud.gd`, and `game/presentation/screens/game_screen.gd` all changed.
`board_view.gd` did not. Every existing single-player-shaped test in `game/tests/test_game_screen.gd`
passes unchanged, since `_on_direction_queued(dir, player: int = 0)` keeps the old single-arg call
shape and player 0's individually-observable behavior for those call sequences is unaffected.
`game/tests/test_input_defaults.gd` auto-covers the new P2 actions since it iterates
`InputDefaults.ACTION_PHYSICAL_KEYCODES` generically. No new ABI/header symbol was added anywhere in
this diff, satisfying AC#1.
51 changes: 43 additions & 8 deletions backlog/tasks/task-051 - Add-local-2-player-support.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: TASK-051
title: Add local 2-player support
status: To Do
status: Done
assignee: []
created_date: '2026-09-09 22:16'
labels: []
Expand All @@ -21,15 +21,50 @@ Add local 2-player support to the Godot presentation and input layers. This shou

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 This task does not modify include/neo_snake.h — verified by git diff on the PR containing this work
- [ ] #2 Two locally-controlled players can play simultaneously on one board with independent input routing
- [ ] #3 Per-player score/status HUD elements both update correctly using ns_player_view_get
- [x] #1 This task does not modify include/neo_snake.h — verified by git diff on the PR containing this work
- [x] #2 Two locally-controlled players can play simultaneously on one board with independent input routing
- [x] #3 Per-player score/status HUD elements both update correctly using ns_player_view_get
<!-- AC:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 task check is green
- [ ] #2 Any deviation from reference/snake.html behavior is recorded in backlog/decisions/, not left implicit
- [ ] #3 Docs touched by the change are updated in the same commit
- [ ] #4 The task file's AC/notes/status are synced in the same commit as the code
- [x] #1 task check is green
- [x] #2 Any deviation from reference/snake.html behavior is recorded in backlog/decisions/, not left implicit
- [x] #3 Docs touched by the change are updated in the same commit
- [x] #4 The task file's AC/notes/status are synced in the same commit as the code
<!-- DOD:END -->

## Notes

<!-- SECTION:NOTES:BEGIN -->
The core-layer 2-player semantics (`core/world.zig`/`core/abi.zig`) were already settled by
[[decision-034]] in a prior session on this branch: shared world-level `status`, per-player `alive`
flag, `playerStatus(w,i)` projection, two-phase collision with head-to-head mutual kill, corpses
never cleared, world only reaches `.dead` once every player is eliminated.

This session's scope was the remaining Godot presentation/input layers, all judgment calls recorded
in [[decision-035]]: `GameScreen` always initializes `player_count = 2` (no togglable 1p/2p mode —
YAGNI, nothing in this task's AC asks for a mode toggle); player 0 keeps arrow keys only and player 1
gets a new WASD-only action set (`game/platform/input_defaults.gd`'s `ACTION_P2_MOVE_*`,
`game/project.godot`'s `p2_move_*` bindings) so a single keypress can't drive both players; touch
swipe and joypad analog stick stay routed to player 0 only (no natural per-player mapping exists for
either on one shared keyboard/no second controller); `GameScreen._shared_status()` reconstructs the
true shared world phase from only `ns_player_view_get` calls (no new ABI/header symbol, satisfying
AC#1) by reading player 0's status and falling back to player 1's only when player 0 reads DEAD;
best-score persistence stays player-0/mode-scoped only (per-player bests are out of scope);
`board_view.gd` needed zero changes since it was already generic over `player` — 2-player rendering
is a second `BoardView` instance (`board_view_p2`) sharing the same `SimulationWorld`; per-player
death/eat fx cues route via the event dict's existing `"player"` field so player 1's death flash
doesn't fire on player 0's board and vice versa.

AC#1 verified via `git diff --stat main -- include/neo_snake.h` producing zero output. AC#2/#3
covered by four new tests in `game/tests/test_game_screen.gd`
(`test_game_screen_wires_a_second_board_view_for_player_1`,
`test_player_1_direction_input_steers_player_1_without_touching_player_0`,
`test_player_0_direction_input_still_defaults_to_player_0`,
`test_hud_reflects_both_players_score_and_status_after_start`). Full `task check` is green: 123 test
cases / 0 errors / 0 failures / 0 flaky / 0 skipped across 20 suites, plus audio/music checks.
`docs/architecture.md` was checked (grep for `direction_queued`/`player_count`/`PLAYER_ACTIONS`) and
needs no update — it documents `reference/snake.html`'s architecture, which this Godot-layer-only
task doesn't touch.
<!-- SECTION:NOTES:END -->
Loading
Loading