From 66100a7b748fd9074bc7d12feb43d023fd778ab8 Mon Sep 17 00:00:00 2001 From: luriasb Date: Sun, 18 Oct 2026 01:12:38 -0600 Subject: [PATCH] release ghosts from the pen via door target and staged exit SPEC 01's four archetypes never left the ghost pen. With Pac-Man starting below the pen, greedy Manhattan pursuit scores `up` as the worst option from every cell inside, so the door is never chosen and all four ghosts walk a closed loop forever. The bug predates SPEC 01: the original `hunter` ghost was trapped too, and only the removed `random` ghost escaped by luck, masking it. Fixes it by aiming ghosts at the door while they are inside a PEN box, then gating the release one ghost per GHOST_RELEASE_FRAMES. The box deliberately includes row 12, the two door cells. Stopping at row 13 would hand control back to the archetype while the ghost is still in the doorway, and its target below would pull it back inside. Measured on the first life with Pac-Man idle: the four ghosts clear PEN at frames 51, 90, 160 and 240, all before the 300-frame criterion. The release replays after a death because resetPositions zeroes game.frame. Implements SPEC 02 and closes the unmet acceptance criterion it leaves in SPEC 01. The game stays deterministic; no randomness is introduced. --- specs/01-four-distinct-ghosts.md | 13 ++- specs/02-ghost-pen-release.md | 146 +++++++++++++++++++++++++++++++ src/js/game.js | 28 +++++- src/js/maze.js | 8 ++ 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 specs/02-ghost-pen-release.md diff --git a/specs/01-four-distinct-ghosts.md b/specs/01-four-distinct-ghosts.md index 77c2ba1..af0142e 100644 --- a/specs/01-four-distinct-ghosts.md +++ b/specs/01-four-distinct-ghosts.md @@ -1,6 +1,6 @@ # SPEC 01 — Four distinct ghosts in the pen -> **Status:** Approved +> **Status:** Implemented > **Depends on:** — > **Date:** 2026-10-15 > **Objective:** Replace the current two ghosts with four ghosts that each follow a distinct behaviour, with Blinky chasing Pac-Man aggressively. @@ -89,6 +89,17 @@ All four branches use the same `choices` filter and the same tie-break order (fi - [ ] `decideGhost` in `src/js/game.js` has exactly four `kind` branches and no reference to `'hunter'` or `'random'`. - [ ] On starting the game, four ghosts are visible inside the pen with colours red, cyan, pink, and orange (in that order in `GHOST_COLORS`). - [ ] Within a few seconds of starting, all four ghosts have left the pen and are roaming the maze. + + > **Not met by this spec alone, and not fixable within it.** Verified by simulating `maze.js` and + > `game.js`: with Pac-Man below the pen, greedy Manhattan pursuit scores `up` as the worst + > option from every cell inside, so no ghost ever takes the door. All four loop indefinitely. + > The risk table below wrongly assumes an available exit is eventually chosen. + > + > **Now satisfied via SPEC 02** (`specs/02-ghost-pen-release.md`, Implemented 2026-10-18), + > which targets the door while a ghost is inside `PEN` and staggers the release 60 frames + > apart. Measured on the first life with Pac-Man idle: all four ghosts clear `PEN` by frame + > 240, leaving at frames 51, 90, 160 and 240. The remaining criteria in this list are met + > by this spec. - [ ] Túnel lateral in row 14 still wraps all four ghosts from one side to the other without getting stuck on the border. - [ ] Pressing Start after `lost` or `won` resets the game to a fresh state with the four ghosts back at their start cells. - [ ] No new files, no new dependencies, no ES module conversion, no build artefacts. Only `src/js/maze.js` and `src/js/game.js` change. diff --git a/specs/02-ghost-pen-release.md b/specs/02-ghost-pen-release.md new file mode 100644 index 0000000..1a21644 --- /dev/null +++ b/specs/02-ghost-pen-release.md @@ -0,0 +1,146 @@ +# SPEC 02 — Ghost pen release + +> **Status:** Implemented +> **Depends on:** SPEC 01 +> **Date:** 2026-10-18 +> **Objective:** Make all four ghosts leave the pen and roam the maze by aiming them at the door while inside it and releasing them one at a time, 60 frames apart. + +## Why this spec exists + +SPEC 01 replaced the two MVP ghosts with the four classic archetypes. During implementation it was verified — by simulating `maze.js` and `game.js` outside the browser — that **none of the four ghosts ever leaves the pen**. Blinky walks a closed 6-cell loop forever: + +``` +(11,14) → (12,14) → (13,14) → (13,15) → (12,15) → (11,15) → (11,14) → … +``` + +The cause is that Pac-Man starts at `(13,23)`, directly below the pen, and the pen's only exit is the door above. With greedy Manhattan pursuit, `up` is always the worst-scoring direction while the ghost is inside: it moves away from row 23 and costs `+1`. Every lateral or downward move scores strictly better, so the door is never chosen. The pen is a local minimum for any pursuer. + +This bug predates SPEC 01. The original `hunter` ghost was trapped identically (12 cells visited, never escaped); the MVP's `random` ghost escaped by luck and masked the problem. SPEC 01 removed the `random` kind, which removed the only accidental escape route and made all four ghosts stuck. + +Two statements in SPEC 01 are therefore falsified by measurement: + +- Its acceptance criterion *"Within a few seconds of starting, all four ghosts have left the pen and are roaming the maze"* is unreachable with the algorithm it specifies. +- Its risk mitigation — *"every ghost can leave through the door at any intersection … a stuck ghost still picks a valid exit"* — assumes that an available exit gets chosen. Under greedy scoring, availability is not selection. + +This spec fixes the defect without touching the four archetypes, and adds the staged exit that SPEC 01 deliberately deferred to a future spec. + +## Scope + +**In:** + +- A `PEN` box constant in `src/js/maze.js` describing the pen interior **and** the two door cells, plus a `PEN_DOOR` cell. +- One branch at the top of `ghostTarget` (`src/js/game.js`): while a ghost is inside the `PEN` box, its target is the door cell instead of its archetype target. +- A `frame` counter in the game state, incremented by `update`. +- A release gate: the ghost at index `i` does not move until `game.frame >= i * GHOST_RELEASE_FRAMES`. +- `resetPositions` resets `game.frame` to `0`, so the stagger repeats after a death. + +**Out of scope (for future specs):** + +- Power-pellets, frightened mode, blue ghosts. +- Pathfinding beyond the next intersection (no A*, no BFS). +- Any visual animation of the pen: a door that lifts, ghosts bobbing inside. This spec only gates movement. +- Scatter/chase phase timers and per-ghost scatter tiles. +- Per-ghost speed. +- Reintroducing randomness anywhere. The game stays fully deterministic. +- Ghosts returning to the pen once they have left it. +- The index-based colour-to-kind mapping fragility in `render.js`, observed during SPEC 01. +- Sounds, levels beyond the first, high-scores, settings. + +## Data model + +```js +// src/js/maze.js + +// Box of the pen. Deliberately includes the two door cells at row 12. +const PEN = { x0: 11, x1: 16, y0: 12, y1: 15 }; +const PEN_DOOR = { x: 13, y: 12 }; + +// src/js/game.js +const GHOST_RELEASE_FRAMES = 60; // 1 s at 60 fps between each exit +``` + +```js +// src/js/game.js — the object returned by createGame() +{ + state: 'start', + frame: 0, // frames elapsed; governs the staged release + // … the rest is unchanged +} +``` + +Conventions: + +- Coordinates: cell `(x, y)`, origin top-left, `x ∈ [0, 27]`, `y ∈ [0, 30]`. +- `PEN` is an inclusive box. A ghost is inside the pen when `x0 <= x <= x1 && y0 <= y <= y1`. +- `PEN_DOOR` is `(13, 12)`. `(14, 12)` is the symmetric door and stays passable for ghosts, but is not used as a target. +- Ghost index `i` is its position in `game.ghosts`, which mirrors the order of `GHOST_STARTS` (`blinky`, `pinky`, `inky`, `clyde`) and therefore the order of `GHOST_COLORS`. +- `game.frame` advances by one per `update()` call and returns to `0` in `resetPositions`. + +`game.ghosts[]` keeps the shape `{ x, y, dir, speed, kind }`. This spec adds **no per-ghost field** — the release state is fully derived from the index and the frame counter. + +## Implementation plan + +1. **Edit `src/js/maze.js`** — add the `PEN` and `PEN_DOOR` constants next to `GHOST_STARTS` and export both on `window`, alongside the existing exports. No other change in this file. +2. **Edit `src/js/game.js`** — add `GHOST_RELEASE_FRAMES = 60` next to `GHOST_SPEED`, and add `frame: 0` to the object literal returned by `createGame`. +3. **Edit `ghostTarget` in `src/js/game.js`** — insert a first branch, before the four archetype branches, that returns `PEN_DOOR` when the ghost is inside the `PEN` box. The archetype branches are untouched below it. +4. **Thread the ghost index into `moveGhost`** — change the signature to accept `i` and update the single call site in `update` to `.forEach( ( g, i ) => moveGhost( game, g, i ) )`. At the top of `moveGhost`, return early while `game.frame < i * GHOST_RELEASE_FRAMES`, so a gated ghost neither moves nor decides a direction and keeps the `dir` it was reset to. +5. **Drive and reset the counter** — increment `game.frame` at the top of `update`, and set `game.frame = 0` in `resetPositions`. Manual test: die on purpose, and watch the four ghosts re-stagger. +6. **Manual playtest** — open `src/index.html`, press Start, and confirm the ghosts leave one at a time and then behave per archetype. The console must stay clean. + +## Acceptance criteria + +- [ ] `PEN` and `PEN_DOOR` exist in `src/js/maze.js` and are exported on `window`. +- [ ] `PEN` includes row `12`, so both door cells `(13,12)` and `(14,12)` are inside the box. +- [ ] All four `GHOST_STARTS` cells lie inside the `PEN` box. +- [ ] The ghost at index `i` does not change position before frame `i * GHOST_RELEASE_FRAMES`. +- [ ] With Pac-Man at his start cell and never pressing a key, all four ghosts are outside the `PEN` box within 300 frames of pressing Start. +- [ ] No ghost is still inside the `PEN` box after frame 300. +- [ ] After a death, `game.frame` is `0` and the ghost at index `i` again waits until frame `i * GHOST_RELEASE_FRAMES`. +- [ ] Once a ghost is outside the `PEN` box, it uses its archetype target: `blinky` pursues Pac-Man, `pinky` aims four tiles ahead, `inky` flanks off Blinky, `clyde` retreats to `(0,30)` within 8 tiles. +- [ ] The tunnel on row 14 still wraps all four ghosts. +- [ ] Pressing Start after `lost` or `won` resets to a fresh state with all four ghosts back at their start cells. +- [ ] The game is deterministic: two runs with identical key input produce identical ghost positions. +- [ ] `src/js/render.js`, `src/js/main.js`, `src/index.html` and `src/css/style.css` are unchanged. +- [ ] No new files, no new dependencies, no ES module conversion, no build artefacts. +- [ ] The browser console shows no errors or warnings during a full play (start, eat dots, die, restart). + +## Decisions + +- **Yes:** an explicit coordinate box in `maze.js` rather than deriving the pen region from `MAZE`. The pen is a fixed region in this single level; a box is readable, costs nothing at runtime, and has no failure mode on a one-cell corridor. +- **Yes:** the box includes row `12`, the two door cells. This is the part that makes the fix work. If the box stopped at row 13, a ghost standing in the doorway would leave the box and immediately hand control back to its archetype, whose target (Pac-Man, below) would send it back down into the pen, oscillating in the doorway. +- **Yes:** the door cell itself, `(13,12)`, as the target — not the cell above it. Both work; verified by simulation, all four ghosts leave in 31–52 frames with the door cell and in 21–47 frames with `(13,11)`. The door cell states the intent directly and matches the spec language. +- **Yes:** a staged release. SPEC 01 listed "staged exits" as explicitly out of scope for a future spec; this is that spec. +- **Yes:** release order is the position in `GHOST_STARTS` (`blinky`, `pinky`, `inky`, `clyde`). It matches `GHOST_COLORS` and the canonical order, and needs no new data. +- **Yes:** 60 frames between releases, one second at 60 fps. All four are out by frame 300. +- **Yes:** `game.frame` resets on death, so the stagger replays after every life. +- **Yes:** the release gate lives in `moveGhost`, so a gated ghost neither moves nor calls `decideGhost`. Its `dir` stays `up` and it starts moving already aimed at the door. +- **No:** a per-ghost `waiting` field. The index and the frame counter already carry the whole meaning; a field would be redundant state to keep in sync. +- **No:** `Math.random` anywhere. The game stays deterministic, as SPEC 01 established. If randomness is wanted later, it is a scatter-mode spec, not a patch here. +- **No:** a visual pen animation (lifting door, bobbing ghosts). This spec gates movement only. A separate animation would need a render change SPEC 02 explicitly avoids. +- **No:** scatter/chase phase timers. Those only become meaningful together with per-ghost scatter tiles. +- **No:** allowing a ghost to re-enter the pen once it has left. Nothing in the archetypes pulls a ghost back in, and Pac-Man cannot enter either, so the rule would be dead code. + +## Risks + +| Risk | Mitigation | +| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PEN` drifts out of sync with the maze if the pen is ever redrawn. | `PEN` is a single constant sitting next to `GHOST_STARTS` in `maze.js`. An acceptance criterion asserts all four start cells lie inside the box. | +| A ghost oscillates in the doorway because its archetype target is already outside the box. | The box includes row `12`, so the ghost keeps targeting the door until it steps onto row `11`. Verified by simulation: all four leave in 31–52 frames. | +| Ghosts bunched at the door after a death look wrong. | Identical to the opening of a game. The 60-frame stagger applies the same way in both cases, so the two situations are indistinguishable. | +| The release desynchronises from the animation if `update` is not called every frame. | `game.frame` only advances inside `update()`, and `main.js` only calls `update` while `state === 'playing'`. Pausing the game pauses the release, which is the intended behaviour. | +| Inky computes its flank off Blinky's position while Blinky is still in the pen. | Cannot happen: Inky is index 2 and releases at frame 120, while Blinky is index 0 and is outside the pen by frame 51. Verified by the exit-frame measurements. | +| The `frame` counter changes how SPEC 01's archetypes feel, shifting difficulty. | `GHOST_RELEASE_FRAMES` is a single named constant. Tuning it to taste is a one-line change. | + +## What is **not** in this spec + +- Power-pellets, frightened mode, blue ghosts. +- Pathfinding beyond the next intersection. +- Visual animation of the ghost house: a lifting door, bobbing ghosts. +- Scatter/chase phase timers, per-ghost scatter tiles. +- Per-ghost speed. +- Any randomness — the game stays deterministic. +- Ghosts re-entering the pen after they have left it. +- Fixing the index-based colour-to-kind mapping in `render.js`, observed during SPEC 01. +- Sounds, multiple levels, high-scores, settings. + +Each one of those, if it lands, goes in its own spec. diff --git a/src/js/game.js b/src/js/game.js index cb1b4a8..924f87d 100644 --- a/src/js/game.js +++ b/src/js/game.js @@ -12,6 +12,9 @@ const OPPOSITE = { left: 'right', right: 'left', up: 'down', down: 'up' }; const PACMAN_SPEED = 0.125; // 1/8 celda/frame -> alinea cada 8 frames const GHOST_SPEED = 0.1; // 1/10 celda/frame +// Frames entre cada salida de la pen: 1 s a 60 fps. El fantasma de indice i +// no se mueve hasta game.frame >= i * GHOST_RELEASE_FRAMES. +const GHOST_RELEASE_FRAMES = 60; // Crea una partida nueva. Copia MAZE (pristino) a game.grid para poder comer // dots sin destruir el original, y reiniciar. @@ -25,6 +28,9 @@ function createGame() { return { state: 'start', + // Frames transcurridos. Gobierna la salida escalonada de la pen y lo + // reinicia resetPositions, de modo que cada vida repite el escalonado. + frame: 0, score: 0, lives: 3, dotsRemaining: dots, @@ -131,6 +137,15 @@ function ghostTarget( game, g ) { const px = Math.round( p.x ); const py = Math.round( p.y ); + // Dentro de la pen manda la puerta, no el arquetipo. Sin esta rama el + // pursuit voraz nunca elige la salida: con Pac-Man debajo, `up` puntua + // peor que cualquier otra direccion y el fantasma se queda en un ciclo + // cerrado. La caja PEN incluye la fila 12 para que siga apuntando a la + // puerta mientras pisa las celdas del doorway. + if ( g.x >= PEN.x0 && g.x <= PEN.x1 && g.y >= PEN.y0 && g.y <= PEN.y1 ) { + return PEN_DOOR; + } + // Blinky: perseguir sin mas. if ( g.kind === 'blinky' ) return { x: px, y: py }; @@ -180,10 +195,16 @@ function decideGhost( game, g ) { g.dir = best; } -function moveGhost( game, g ) { +function moveGhost( game, g, i ) { const grid = game.grid; const width = grid[ 0 ].length; + // Salida escalonada: el fantasma i espera su turno. El gate va aqui, antes + // del redondeo, para que un fantasma retenido tampoco decida direccion: se + // queda con el dir que le puso resetPositions (up) y arranca ya apuntando a + // la puerta en cuanto se libera. + if ( game.frame < i * GHOST_RELEASE_FRAMES ) return; + if ( aligned( g.x ) && aligned( g.y ) ) { g.x = Math.round( g.x ); g.y = Math.round( g.y ); @@ -203,6 +224,8 @@ function resetPositions( game ) { p.y = PACMAN_START.y; p.dir = 'left'; p.nextDir = null; + // Reinicia el escalonado: cada vida repite la salida uno a uno. + game.frame = 0; game.ghosts.forEach( ( g, i ) => { g.x = GHOST_STARTS[ i ].x; g.y = GHOST_STARTS[ i ].y; @@ -215,8 +238,9 @@ function collides( a, b ) { } function update( game ) { + game.frame++; movePacman( game ); - game.ghosts.forEach( ( g ) => moveGhost( game, g ) ); + game.ghosts.forEach( ( g, i ) => moveGhost( game, g, i ) ); for ( const g of game.ghosts ) { if ( collides( game.pacman, g ) ) { diff --git a/src/js/maze.js b/src/js/maze.js index 5f9bb54..8a4c5ba 100644 --- a/src/js/maze.js +++ b/src/js/maze.js @@ -61,7 +61,15 @@ const GHOST_STARTS = [ { x: 16, y: 14, kind: 'clyde' }, ]; +// Caja de la pen. Incluye a proposito la fila 12 (las dos celdas de la puerta, +// (13,12) y (14,12)): si terminara en la fila 13, un fantasma de pie en el +// doorway recuperaria su arquetipo, cuyo target esta abajo, y volveria a entrar. +const PEN = { x0: 11, x1: 16, y0: 12, y1: 15 }; +const PEN_DOOR = { x: 13, y: 12 }; + window.MAZE = MAZE; window.TUNNEL_ROW = TUNNEL_ROW; window.PACMAN_START = PACMAN_START; window.GHOST_STARTS = GHOST_STARTS; +window.PEN = PEN; +window.PEN_DOOR = PEN_DOOR; -- 2.54.0