Files
open-pacman/specs/02-ghost-pen-release.md
T
luriasb 66100a7b74 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.
2026-10-18 01:12:38 -06:00

12 KiB
Raw Blame History

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

// 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
// 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.