10 KiB
SPEC 01 — Four distinct ghosts in the pen
Status: Approved 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.
- 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.