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.
11 KiB
SPEC 01 — Four distinct ghosts in the pen
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.
Why this spec exists
The current MVP ships only two ghosts in GHOST_STARTS ('hunter' and 'random') and a single branch in decideGhost that swaps between a Manhattan-distance chase and a uniform random walk. The user wants four ghosts with visibly different behaviours, anchored on the classic Pac-Man archetypes, while keeping the rest of the game (movement, collisions, render, overlay) untouched.
The render layer is already prepared for four ghosts — render.js declares exactly four colours in GHOST_COLORS. This spec leverages that and changes only the data and the decision function.
Scope
In:
- Four entries in
GHOST_STARTS(src/js/maze.js), placed inside the pen, symmetric with respect to the maze's vertical axis. - Four
kindvalues:'blinky','pinky','inky','clyde'. - Four behaviour branches in
decideGhost(src/js/game.js), one perkind. - Same speed for all four ghosts (the existing
GHOST_SPEED). - Colour per archetype (the existing
GHOST_COLORSorder already maps 1:1 to the four kinds in the order Blinky, Pinky, Inky, Clyde).
Out of scope (for future specs):
- Power-pellets, frightened mode, blue ghosts.
- Pathfinding beyond the next intersection (no A*, no BFS).
- Ghost house release animation or staged exits (all four spawn inside the pen and walk out through the door like today).
- Per-ghost speed, per-ghost scatter tiles, per-ghost animation state.
- Sounds, levels beyond the first, high-scores, settings.
Data model
// src/js/maze.js
const GHOST_STARTS = [
{ x: 11, y: 14, kind: 'blinky' },
{ x: 13, y: 14, kind: 'pinky' },
{ x: 14, y: 14, kind: 'inky' },
{ x: 16, y: 14, kind: 'clyde' },
];
Conventions:
- Coordinates: cell
(x, y), origin top-left,x ∈ [0, 27],y ∈ [0, 30]. - The four start cells are symmetric: columns 11↔16 and 13↔14 mirror across the maze's vertical centre line.
- The pen door at columns 13–14, row 12 stays clear (no ghost spawns on
(13,12)or(14,12)).
game.ghosts[] keeps the existing shape { x, y, dir, speed, kind }. resetPositions already iterates by index, so it scales to four entries with no change.
// src/js/game.js — relevant globals stay the same
const GHOST_SPEED = 0.1;
const DIRS = { left: { x: -1, y: 0 }, right: { x: 1, y: 0 }, up: { x: 0, y: -1 }, down: { x: 0, y: 1 } };
const OPPOSITE = { left: 'right', right: 'left', up: 'down', down: 'up' };
No new structures are introduced. Render colours are reused:
// src/js/render.js — already declares 4 colours, no change
const GHOST_COLORS = [ '#ff0000', '#00ffff', '#ffb8ff', '#ffb852' ];
Implementation plan
- Edit
src/js/maze.js— replace the current two-entryGHOST_STARTSwith the four-entry array shown in the data model. No other change in this file. - Edit
src/js/game.jsdecideGhost— replace the existing two-branchif (g.kind === 'hunter') … else …with the four behaviours described below. Keep theoptions/choicesfiltering against the opposite direction intact, so every ghost still avoids 180° reversals at intersections. - Verify
resetPositions— read the file and confirm theforEach( ( g, i ) => GHOST_STARTS[ i ] )loop still works with four entries. No code change expected; document the confirmation. - Verify
isWallandwrapTunnel— read the file and confirm the pen door is still passable foractor === 'ghost'and that the tunnel wrap still triggers on row 14. No code change expected. - Verify
render.js— read the file and confirmGHOST_COLORSalready has four entries andforEach( ( g, i ) => GHOST_COLORS[ i ] || '#ff0000' )paints all four. No code change expected. - Manual playtest — open
src/index.htmlin a browser, click Start, and observe the four ghosts. The pen must not trap any ghost permanently (all four walk out through the door on their own).
Behaviour definitions for decideGhost
For each ghost, the candidates are choices (the existing filter that excludes the opposite of g.dir and any wall; if empty, the opposite is allowed as a fallback). The branch picks one:
- Blinky (
'blinky') — pursue. For each candidate, compute|nx − round(pacman.x)| + |ny − round(pacman.y)|and pick the minimum. This is the current'hunter'branch, renamed. - Pinky (
'pinky') — ambush. Target cell is four tiles ahead of Pac-Man inpacman.dir(wrapping to(0,14)or(27,14)when the offset leaves the maze horizontally — i.e. when the target is past the tunnel, anchor it to Pac-Man's actual column). Pick the candidate that minimises Manhattan distance to that target. - Inky (
'inky') — flank. Compute the cell two tiles ahead of Pac-Man inpacman.dir(same wrap rule as Pinky), then the Inky target is the reflection of Blinky's cell through that pivot:target = 2 * pivot − blinkyCell. Pick the candidate that minimises Manhattan distance to that target. If Blinky is missing (it never is in this spec, but defensively), fall back to Pinky's target. - Clyde (
'clyde') — shy. If|gx − round(pacman.x)| + |gy − round(pacman.y)| > 8, behave like Blinky (pursue). Otherwise, target Clyde's home corner(0, 30)and pick the candidate that minimises Manhattan distance to that corner.
All four branches use the same choices filter and the same tie-break order (first best in the iteration order). They differ only in which target cell they compare against.
Acceptance criteria
-
GHOST_STARTSinsrc/js/maze.jshas exactly four entries, all withy === 14andkindin{ 'blinky', 'pinky', 'inky', 'clyde' }. -
decideGhostinsrc/js/game.jshas exactly fourkindbranches 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
lostorwonresets 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.jsandsrc/js/game.jschange. -
The browser console shows no errors or warnings during a full play (start, eat some dots, die, restart).
Decisions
- Yes: four ghosts inside the pen, symmetric. Keeps the existing maze untouched, respects the vertical symmetry already enforced in
MAZE_STR, and leaves the pen door clear. - No: two inside and two outside. Would require either a second spawn mechanism or a maze edit, both outside the scope of this spec.
- Yes: rename
'hunter'→'blinky'and drop'random'. The four classic archetypes subsume the old'random'behaviour (Clyde's "shy" mode and Pinky's ambush produce enough variety without a pure-random ghost). - No: keep names descriptive (
'ambush','flank','shy','aggressive'). The classic names are short, recognisable, and match what the player will read online. - Yes: same
GHOST_SPEED = 0.1for all four. Simplifies balance and keeps the existing speed constant untouched. - No: per-ghost speed. Belongs to a future balancing spec.
- Yes: Pinky and Inky clamp their target to within maze bounds when the four-tile offset would overshoot horizontally (anchor to Pac-Man's column when the target row is the tunnel row). Prevents targeting a non-existent cell.
- No: A* or BFS pathfinding. The intersection-decision model is enough for four distinct behaviours and matches the existing code style.
- Yes: Inky's fallback when Blinky is missing is Pinky's target. Defensive — never triggers today, costs three lines.
- No: power-pellets, frightened mode, blue ghosts, ghost house release animation. All deferred to later specs.
Risks
| Risk | Mitigation |
|---|---|
| A ghost gets trapped inside the pen because its target is also inside. | isWall( actor = 'ghost' ) already ignores the pen door, so every ghost can leave through the door at any intersection. The choices filter never includes walls, so a stuck ghost still picks a valid exit. |
| Asymmetric spawn cells look off. | The four cells (11,14), (13,14), (14,14), (16,14) are symmetric across the maze's vertical axis (cols 11↔16, 13↔14), matching the symmetry already present in MAZE_STR. |
Renaming 'hunter' breaks any external consumer. |
kind is only read inside decideGhost; ghosts is created from GHOST_STARTS and never serialized. No consumer exists outside this repo. |
| Inky or Pinky target a cell outside the maze on wrap. | The implementation plan explicitly bounds the target to maze columns using Math.max(0, Math.min(W − 1, …)), and the tunnel-row special case anchors to Pac-Man's column. |
What is not in this spec
- Power-pellets and frightened mode.
- Pathfinding beyond the next intersection.
- Ghost house release animation or staged exits.
- Per-ghost speed, per-ghost scatter tiles, per-ghost animation state.
- Sounds, multiple levels, high-scores, settings.
- Any new file, dependency, bundler, or build step.
Each one of those, if it lands, goes in its own spec.