Top-down arcade maze shooter in Phaser 3. Steps 1-6 of plan.md complete and verified: vibe drain, generators/swarm, pooling, combat, Sound Guy, airhorn, trainspot exit, level flow, announcer. assets/ holds the FLUX prompt manifest + MODELBEAST enqueuer for the Step 7 sprite pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
183 lines
8.9 KiB
Markdown
183 lines
8.9 KiB
Markdown
# VINYL GAUNTLET — Implementation Plan
|
||
|
||
A top-down arcade maze shooter that clones the mechanics of **Gauntlet II (1986)**,
|
||
re-skinned as record-collecting / DJ / trainspotting culture. You dig through record
|
||
fairs and warehouse clubs, fight off swarming clubbers, and reach the DJ Booth to
|
||
trainspot the track and clear the level.
|
||
|
||
**Execute the steps IN ORDER. Each step ends with an acceptance check — do not start
|
||
the next step until the check passes in the browser. Do not add features not listed
|
||
here. Placeholder geometric shapes throughout; no sprite art until Step 7.**
|
||
|
||
## Tech stack
|
||
|
||
- Vite + vanilla JS (no TypeScript, no framework)
|
||
- Phaser 3 (`npm install phaser`), Arcade Physics, zero gravity, top-down
|
||
- One canvas 960x720. Tile size 32px.
|
||
- No external assets: shapes are `add.rectangle`/`add.circle` converted to physics
|
||
bodies (or generated textures via `make.graphics`). Voice via `window.speechSynthesis`.
|
||
The single sound effect (airhorn) via WebAudio oscillator — no audio files.
|
||
|
||
## File layout (keep it this small)
|
||
|
||
```
|
||
index.html
|
||
src/main.js — Phaser config + scene registration
|
||
src/GameScene.js — the whole game (it's fine, it's an arcade game)
|
||
src/levels.js — level maps as 2D arrays + legend
|
||
src/announcer.js — speechSynthesis wrapper + line table
|
||
```
|
||
|
||
## Core constants (put at top of GameScene.js, tune later)
|
||
|
||
```js
|
||
const TILE = 32;
|
||
const VIBE_MAX = 2000;
|
||
const VIBE_DRAIN_PER_SEC = 1; // the Gauntlet heartbeat
|
||
const ESPRESSO_VIBE = 500;
|
||
const POSER_TOUCH_DAMAGE = 30;
|
||
const NERD_TOUCH_DAMAGE = 80;
|
||
const SOUNDGUY_DRAIN = 10; // per 250ms tick while overlapping
|
||
const GEN_HP = 3;
|
||
const GEN_SPAWN_MS = 2500; // base; shrinks with crowd density
|
||
const PLAYER_SPEED = 180;
|
||
const POSER_SPEED = 120;
|
||
const NERD_SPEED = 70;
|
||
const SOUNDGUY_SPEED = 55;
|
||
const VINYL_SPEED = 400;
|
||
const FIRE_COOLDOWN_MS = 180;
|
||
const TRAINSPOT_MS = 3000;
|
||
```
|
||
|
||
---
|
||
|
||
## Step 1 — Project, tilemap, movement
|
||
|
||
- `npm create vite@latest . -- --template vanilla`, then `npm install phaser`.
|
||
- Phaser config: 960x720, arcade physics, `gravity: {y: 0}`, pixelArt true.
|
||
- **Levels are 2D arrays of single characters** in `src/levels.js`:
|
||
```
|
||
W = wall (crate stack, brown) . = floor (dark grey #1a1a1a)
|
||
P = player start E = espresso (green square)
|
||
K = lanyard/key (yellow square) D = velvet rope door (red block)
|
||
G = generator/discount bin (purple) X = DJ booth exit (magenta, pulsing alpha tween)
|
||
S = sound guy spawn A = airhorn pickup (white square)
|
||
```
|
||
Export `LEVELS = [level1, level2, level3]` — author 3 maps by hand, each ~30x25
|
||
tiles, Gauntlet-style: rooms, corridors, a keyed door between player and exit,
|
||
2–4 generators. Level 1 easy (1 generator, no sound guy), level 3 nasty.
|
||
- Build the level in `buildLevel(index)`: walls into a `staticGroup`, everything else
|
||
into groups/objects. This function must fully tear down the previous level first
|
||
(clear groups, timers, colliders) — it gets called on every level change and restart.
|
||
- Player: cyan circle, 8-way WASD movement, normalized diagonal speed, collides with
|
||
walls. Camera follows player with lerp; world bounds = map size.
|
||
|
||
**Check:** player drives around level 1, can't pass through walls, camera follows.
|
||
|
||
## Step 2 — Vibe drain, pickups, doors, HUD
|
||
|
||
- `this.vibe = VIBE_MAX`. A 1-second looped timer subtracts `VIBE_DRAIN_PER_SEC`.
|
||
- HUD (fixed to camera): `VIBE 1987 LANYARDS 1 AIRHORNS 1 LEVEL 1`.
|
||
Vibe text turns yellow below 500, red below 200.
|
||
- Espresso overlap: +`ESPRESSO_VIBE` (cap at `VIBE_MAX`), destroy pickup.
|
||
- Lanyard overlap: `lanyards++`, destroy pickup.
|
||
- Velvet rope: static red blocks that collide like walls; on collide, if
|
||
`lanyards > 0` then `lanyards--` and destroy the block.
|
||
- Vibe <= 0 → GAME_OVER state: freeze physics, "TRAINWRECK" centered, press R to
|
||
restart at level 1 with fresh vibe.
|
||
- Game states as a plain string field: `TITLE | PLAYING | GAME_OVER | LEVEL_CLEAR`.
|
||
Title screen = level shown behind a "VINYL GAUNTLET — press any key" overlay.
|
||
|
||
**Check:** vibe visibly ticks down, espresso refills it, a lanyard opens a rope,
|
||
letting vibe hit 0 shows game over, R restarts.
|
||
|
||
## Step 3 — Shooting, generators, the swarm
|
||
|
||
- **Firing:** arrow keys fire vinyl (small black circle with white center dot) in 8
|
||
directions (combinations of arrows), independent of movement direction — this is
|
||
the Gauntlet control scheme. Rate-limit with `FIRE_COOLDOWN_MS`. Vinyl dies on
|
||
wall impact or after 1200ms.
|
||
- **Object pooling (CRITICAL):** one `physics.add.group` for vinyl (size 30), one for
|
||
enemies (size 80). Spawn with `group.get()`, kill with
|
||
`setActive(false).setVisible(false)` + disable body. Never `new`/`destroy` per shot.
|
||
- **Generators (Discount Bins):** purple squares, `GEN_HP` hits. Each has its own
|
||
looped spawn timer (`GEN_SPAWN_MS`): if it's within ~700px of the player, spawn an
|
||
enemy on an adjacent floor tile. Vinyl hit → hp--, brief white flash tween; at 0,
|
||
destroy generator and its timer.
|
||
- **Two spawned enemy types** (generator picks 70/30):
|
||
- **Poser** (red triangle): fast, `moveToObject` toward player every 400ms.
|
||
- **Gear Nerd** (orange square): slow, same homing, bigger touch damage — the
|
||
"ghost" that corners you and drains you with synth talk.
|
||
- **Crowd density:** `this.density = 1`, +0.05 every 10s on the current level;
|
||
effective spawn interval = `GEN_SPAWN_MS / density`. Resets each level.
|
||
|
||
**Check:** shooting feels like Gauntlet (move one way, shoot another), bins pour out
|
||
enemies until destroyed, 50+ enemies on screen holds 60fps.
|
||
|
||
## Step 4 — Combat, the Sound Guy, the Airhorn
|
||
|
||
- Vinyl overlaps enemy → both deactivated (back to pool).
|
||
- Enemy overlaps player → enemy deactivated, vibe -= its touch damage, 100ms red
|
||
tint flash on player. (Enemy dies on contact = authentic Gauntlet grunt behavior
|
||
and prevents per-frame drain stacking.)
|
||
- **The Sound Guy** (Gauntlet's Death): black square with a white "SND" label, one
|
||
per level at the `S` tile (levels 2+). No wall collider — phases through
|
||
everything. Homes at `SOUNDGUY_SPEED`. Immune to vinyl (vinyl passes through).
|
||
While overlapping the player, drain `SOUNDGUY_DRAIN` per 250ms tick — NOT per
|
||
frame. Only the airhorn removes him.
|
||
- **Airhorn (smart bomb):** spacebar, if `airhorns > 0`: white full-screen flash
|
||
(200ms), camera shake, deactivate every active enemy AND the sound guy
|
||
(generators survive), play a two-tone WebAudio oscillator blast (BWAA-BWAA).
|
||
`A` pickups grant +1; player starts with 1.
|
||
|
||
**Check:** shooting clears swarms, sound guy walks through walls and is only
|
||
stoppable by airhorn, airhorn wipes the screen.
|
||
|
||
## Step 5 — Trainspotting exit + level flow
|
||
|
||
- **DJ Booth (`X`):** while the player overlaps it, a progress bar fills over
|
||
`TRAINSPOT_MS` ("TRAINSPOTTING..."). Taking ANY damage or leaving the tile resets
|
||
the bar to zero. This is the level's tension peak — enemies keep coming.
|
||
- Bar full → LEVEL_CLEAR: freeze 1.5s, "TUNE IDENTIFIED", then `buildLevel(next)`.
|
||
Vibe, lanyards, airhorns CARRY OVER (Gauntlet rule — the drain is the difficulty
|
||
curve). After the last level, loop back to level 1 with `density` starting higher.
|
||
|
||
**Check:** standing at the booth under pressure fills the bar, getting hit resets
|
||
it, level 2 loads with vibe carried over.
|
||
|
||
## Step 6 — The Announcer
|
||
|
||
`src/announcer.js`: wrapper around `speechSynthesis` — cancel any current utterance
|
||
before speaking, pick the most robotic-sounding available voice, `rate 0.9, pitch 0.6`.
|
||
Per-line cooldowns (min 8s per line key) so it never spams. Lines:
|
||
|
||
| Trigger | Line |
|
||
|---|---|
|
||
| vibe crosses below 500 | "Selector needs espresso badly." |
|
||
| vibe crosses below 200 | "Selector... is about to die." |
|
||
| game over | "Trainwreck. Absolute trainwreck." |
|
||
| shot a generator to death | "Discount bin... destroyed." |
|
||
| sound guy spawns | "The sound guy... is coming." |
|
||
| airhorn used | (no speech — the oscillator blast IS the line) |
|
||
| door opened | "Velvet rope... unlocked." |
|
||
| level clear | "Tune identified. Selector levels up." |
|
||
| touching booth, bar filling | "Trainspotting..." (once per attempt) |
|
||
|
||
**Check:** lines fire at the right moments, never overlap or spam.
|
||
|
||
## Step 7 — Juice pass (only after 1–6 are flawless)
|
||
|
||
Small, in this order, stop when it feels good:
|
||
1. Vibe < 200: red vignette (a camera-fixed rectangle with low alpha).
|
||
2. Enemy death: 3-particle puff (pooled).
|
||
3. Booth pulses brighter as the trainspot bar fills.
|
||
4. Only now, optionally swap shapes for sprites — the game must already be fun as
|
||
rectangles.
|
||
|
||
## Explicit NON-GOALS (do not build, do not scaffold for)
|
||
|
||
Multiplayer. Digging minigame. Finite ammo / vinyl-as-currency. Ranged enemies.
|
||
Level editors or Tiled/JSON maps. Vibe-based movement penalties. Saving/persistence.
|
||
Mobile controls. More than 3 enemy types. Any menu beyond title/game-over text.
|
||
If any of these seem needed, they aren't — finish the loop first.
|