Files
open-pacman/specs/01-four-distinct-ghosts.md
T
2026-10-16 12:17:18 -06:00

129 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `kind` values: `'blinky'`, `'pinky'`, `'inky'`, `'clyde'`.
- Four behaviour branches in `decideGhost` (`src/js/game.js`), one per `kind`.
- Same speed for all four ghosts (the existing `GHOST_SPEED`).
- Colour per archetype (the existing `GHOST_COLORS` order 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
```js
// 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.
```js
// 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:
```js
// src/js/render.js — already declares 4 colours, no change
const GHOST_COLORS = [ '#ff0000', '#00ffff', '#ffb8ff', '#ffb852' ];
```
## Implementation plan
1. **Edit `src/js/maze.js`** — replace the current two-entry `GHOST_STARTS` with the four-entry array shown in the data model. No other change in this file.
2. **Edit `src/js/game.js` `decideGhost`** — replace the existing two-branch `if (g.kind === 'hunter') … else …` with the four behaviours described below. Keep the `options`/`choices` filtering against the opposite direction intact, so every ghost still avoids 180° reversals at intersections.
3. **Verify `resetPositions`** — read the file and confirm the `forEach( ( g, i ) => GHOST_STARTS[ i ] )` loop still works with four entries. No code change expected; document the confirmation.
4. **Verify `isWall` and `wrapTunnel`** — read the file and confirm the pen door is still passable for `actor === 'ghost'` and that the tunnel wrap still triggers on row 14. No code change expected.
5. **Verify `render.js`** — read the file and confirm `GHOST_COLORS` already has four entries and `forEach( ( g, i ) => GHOST_COLORS[ i ] || '#ff0000' )` paints all four. No code change expected.
6. **Manual playtest** — open `src/index.html` in 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 in `pacman.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 in `pacman.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_STARTS` in `src/js/maze.js` has exactly four entries, all with `y === 14` and `kind` in `{ 'blinky', 'pinky', 'inky', 'clyde' }`.
- [ ] `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.
- [ ] 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.
- [ ] 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.1` for 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.