-
Target session length: 90–180s.
-
Loop:
- Forward run → choose math gates (+ / − / × / ÷).
- Skirmish vs enemy squad (quick volley exchange, deterministic).
- Reverse chase back toward start; survive to finish.
- End card with star rating & restart.
-
Pace: Instant restarts, no loading hitches, minimal UI friction.
- Web (mobile-first), responsive desktop support.
- Min perf targets: 60 FPS on mid-tier mobile; stable 30 FPS on low-end.
- Draw calls: < 150 during peak. Active particles: ≤ ~250.
- Fallbacks: If avg FPS < 50 for 2s → auto degrade (see §8 Perf Guards).
-
Style: Low-poly “Candy Arcade”; bright, juicy, extremely readable.
-
Palette (Mobile-First Snap):
- Primary set:
#ff5fa2(pink),#33d6a6(teal),#ffd166(yellow), background gradient#12151a → #1e2633. - Unit colors: Blue vs Orange — Player
#00d1ff, Enemy#ff7a59.
- Primary set:
-
Lighting: Use MeshMatcapMaterial for units/props (lighting-independent). Simple hemi+dir light for environment props (no real shadows).
-
Post-FX: FXAA; Selective Bloom only on gate numerals/symbols and arrow trails (intensity ~0.6, threshold ~0.85, smoothing ~0.1). No OutlinePass.
-
Rig: Rail-Follow (Catmull-Rom) with 3 segments: Forward → Skirmish → Reverse. Prebake ~120 samples/segment.
-
Defaults:
- Forward: height 8, behind 10, FOV 60, easeInOutQuad.
- Skirmish: height 7, behind 12, tiny ±6° yaw sway.
- Reverse: height 6.5, behind 9, snap-zoom +1.5% FOV at start.
- Look target: player centroid with lerp 0.12.
- Shake: 0.25 amp, 90 ms on big hits (≤1/500 ms).
-
Clip: near/far = 0.1 / 200. Motion blur: none.
- Top-center: score + wave timer (tabular-nums).
- Bottom-left: steering slider (thumb enlarges while dragging, hit-area ≥44×44px).
- Bottom: big Start/Restart pill.
- Top-right: pause menu (resume/restart/mute).
- Gate labels (in-scene): giant operator + number, color-coded (+ green, − red, × yellow, ÷ blue); the numeral card is in the bloom include list.
- Number formatting: compact (1.2k); deltas flash 250 ms (green gain / red loss).
- Transitions: 120–160 ms fades/slides; no bounces.
- Arrow trails: billboard quads (instanced), 6 segments, lifetime 220 ms, additive blend, 64×64 soft-glow texture. Max concurrent emitters: 12.
- Hit sparks: 8 sprite particles on impact, 160 ms, size 6→2 px.
- Damage flash: enemy
emissiveIntensity0→0.8 over 60 ms, back to 0 in 80 ms. - Gate glow: via selective bloom only on numerals/symbols.
- Camera juice: as §4.
- Auto-degrade: halve trail segments (3), cap emitters 6, disable sparks when <50 FPS (see §8).
- Lane: 10 m wide strip with two pastel edge lines + dashed center (vertex colors; no textures).
- Gradient sky/backdrop:
#12151a → #1e2633. - Fog: linear start 25 m, end 60 m (masks strip pooling).
- Mesh: Two chunky pillars + shallow arch (≤200 tris).
- Numeral card: separate emissive quad above arch; in bloom include list.
- Dims: W 3.2 m, H 2.8 m, D 0.4 m.
- Scale: 1 unit = 1 meter; Y-up.
- Tris budgets: Soldier 350–450, Enemy 400–500, Arrow ≤20.
- Instancing: players, enemies, arrows, sparks, props.
- Pivots: characters at foot center (0,0,0); gate ground center; arrow pivot at tail.
-
Baseline:
- Flag post ≤120 tris, H≈1.6 m.
- Cone ≤80 tris, H≈0.45 m (placed as pairs 0.6 m apart).
-
Flair:
- Track marker ≤100 tris, H≈0.35 m; two markers 2 m before every other gate.
-
Placement rules: Per 10 m segment place either 1 cone pair or 1 flag (70/30). Keep ≥0.6 m off lane edge; ≥1.5 m clear of gate pillars. Markers desaturated so numerals remain brightest.
-
Culling: frustum + CPU early-out > 65 m behind camera.
-
Spawn: deterministic from run seed.
- Rocks: Low-poly gumdrop rocks (≤150 tris) intermittently hug the divider with a 0.5 m buffer; they appear on seeded intervals independent of props.
- Avoidance: Player flock pathing treats obstacles as soft-collide volumes—agents slide along them but remain within lane bounds.
- Straggler rule: Any unit that drifts outside the lane or stays beyond the buffer for >2 s is despawned with a subtle dissolve so the formation stays tight.
- FPS monitor: rolling avg over 2 s.
- Degrade step 1 (auto): trails 6→3 segments, emitters 12→6, disable sparks, tighten bloom resolution.
- Upgrade (auto): if ≥58 FPS for 4 s, revert to full Ultra-Lean.
- Hard-safe mode: manual setting “Low” = No-Bloom Fallback (no composer bloom; baked trail glow texture).
-
Operators: Base operations
+a,−b,×c,÷d(a,b,c,d are per-gate values in ranges tuned per wave). -
Rounding:
- After
+/−: clamp ≥0. - After
×/÷: round to nearest integer, min 1; clamp to [1, MAX_ARMY].
- After
-
Balance rules: never generate
−that would kill all units;÷never below 1. -
Operation tiers: Waves 1–5 use single-step expressions; waves 6–10 unlock two-step combos (e.g.
×4−2); waves 11+ may introduce short parenthetical or exponent variants. Every composite gate resolves to the same clamp/round pipeline above. -
Evaluation pipeline: Composite gates are generated from a vetted template set (mul-add, add-mul, pow-div, etc.) and evaluate deterministically left-to-right unless parentheses are present. Apply rounding/clamping only after the full expression resolves; intermediate steps must stay ≥0 (designers drop any template that would violate this with configured ranges).
-
Two-gate choice: place two gates per decision point; values drawn to create meaningful deltas (≥15% difference at early waves, ≥25% later).
-
Color coding: + green, − red, × yellow, ÷ blue.
- Speed: base lane speed
v0, ramps up slightly each wave. - Steer input: horizontal factor ∈ [−1, +1] from slider.
- Flock simulation: Use a lightweight GPU boids pass (inspired by three.js GPGPU birds) to keep large formations cohesive while responding to steering and obstacle avoidance. For low-spec fallback, degrade to CPU formation offsets.
- Tick: every 150 ms both sides exchange damage.
- Damage model:
damage = base * min(attackerCount, defenderCount) ^ 0.85. - Casualty calc: casualties per tick =
ceil(damage / HP_PER_UNIT); clamp ≤ current count. - Enemy sizing: Enemy squads spawn at ~80% of the optimal player count projected for that decision, keeping pressure while preserving a winnable path.
- Volleys: spawn arrow particles proportional to casualties (capped) for visual feedback.
- End of skirmish: side reaching 0 loses; survivor proceeds with remaining units. Time to kill must fit the snackable pace (2–4 ticks typical).
- Determinism: seeded RNG for slight spread; same seed → same result.
- Setup: spawn a chasing enemy horde at distance
D0; speed slightly higher than player (vChase = v0*1.05). - Gate mirror: Reverse phase reuses the forward-run gate count for the current wave, with the same math rules applied to shrinking army sizes.
- Volley pressure: Fire an automatic arrow volley every 0.8 s sized to ~10% of the current player army; arrows target and remove chasers on hit using the skirmish arrow FX/pools.
- Speed profile: Baseline forward/reverse travel speed is 6 m/s; clearing a reverse gate triggers a 1 s chase surge where the horde spikes to 8 m/s before easing back to baseline.
- Win/Lose: reach finish line with ≥1 unit → win; if caught or unit count hits 0 → fail; a failed chase resets progression to wave 1 before the next attempt.
- Difficulty envelope: Tune surge distance and volley effectiveness so a player who maintains ≥70% of the optimal count survives with a small buffer, while dropping below ~50% creates a credible fail risk without feeling impossible.
-
Score: gated on efficiency and remaining units. Example:
- Gate choice bonus (+perfect bonus if >90% of theoretical optimum across decisions).
- Skirmish speed bonus (fewer ticks).
- Survival multiplier for reverse chase.
-
Star bands: 1★ / 2★ / 3★ at ~40% / 70% / 90% of level’s theoretical max.
-
Persistence: LocalStorage stores
{ highScore, bestStars, lastSeed }plus a per-wave map of best star ratings to drive progression UI. -
Wave flow: After each wave, show a minimalist "Wave X Complete" popup with the current 1–3★ result (optionally show a 5★ breakdown for deeper post-run insights) and
Next/Retryoptions; the global Play button advances to the next unfinished wave by default. -
Seeded runs: shareable seed param (
?seed=XXXX).
- Gate counts: Wave 1 features 5 forward-run gates; each new wave adds +1 gate (tunable cap) before transitioning to the skirmish beat and mirrored reverse run.
- Deterministic pairing: Forward and reverse gate sets derive from the same seeded generator so that a given
seed+waveproduces identical layouts across sessions.
-
Stack:
three.js+ pmndrs postprocessing (FXAA, Selective Bloom). Optional troika-three-text for desktop counters (mobile uses DOM). -
Renderer:
antialias:false(AA via composer), powerPreference"high-performance". -
Core modules:
Game.ts(state machine: PreRun → Running → Skirmish → Reverse → EndCard).World.ts(lane pooling, fog, gradient backdrop).Gates.ts(spawn, math values, color, bloom list control).Units.ts(instancing, counts, simple formation layout).Combat.ts(skirmish ticks, damage model, arrow spark emit).VFX.ts(trails, sparks, flashes; performance guards).CameraRig.ts(rail samples, beat transitions, shake).UI.tsxorui.ts(DOM HUD & slider; pause; end card).Flock.ts(GPU boids update step + CPU fallback, straggler cleanup hooks).SeedRng.ts(seedable PRNG).Perf.ts(FPS monitor, degrade/upgrade).Telemetry.ts(abstract event interface withtrackEvent(name, payload); default console logger; ready for external analytics).
-
Object pooling: arrows, sparks, props are pooled; InstancedMesh per type; per-instance attributes for color/scale/opacity.
-
Selective bloom list: numeral cards, trail material. Everything else excluded.
{
"VFX": {
"TRAIL_SEGMENTS": 6,
"TRAIL_LIFETIME_MS": 220,
"SPARK_COUNT": 8,
"BLOOM_INTENSITY": 0.6,
"BLOOM_THRESHOLD": 0.85,
"BLOOM_SMOOTHING": 0.1,
"SHAKE_AMP": 0.25,
"SHAKE_MS": 90
},
"CAMERA": {
"FOV": 60,
"FORWARD": { "height": 8, "behind": 10 },
"SKIRMISH": { "height": 7, "behind": 12, "yawSwayDeg": 6 },
"REVERSE": { "height": 6.5, "behind": 9 },
"LOOK_LERP": 0.12
},
"TERRAIN": { "FOG_START": 25, "FOG_END": 60 },
"GATES": { "WIDTH": 3.2, "HEIGHT": 2.8, "DEPTH": 0.4 },
"PROPS": { "DENSITY": "MEDIUM" }
}- Controls: one-hand slider, tap buttons; Arrow keys/A/D on desktop as mirror input (optional).
- Readability: high contrast HUD; color-coding supplemented by symbols/operators (color-blind friendly).
- Haptics (optional mobile): short vibration on perfect gate and win.
- WebGL unavailable: show lightweight fallback screen with instructions to enable hardware acceleration.
- Lost context / tab hidden: pause and show resume.
- Bad seed / params: validate and clamp to defaults.
- Division gates: enforce min result 1 after rounding; never generate
÷0or÷that yields <1.
-
Unit (Jest):
- Gate math & rounding rules (Given/When/Then).
- Gate generator never emits invalid combos.
- Combat tick determinism for a fixed seed.
- Performance guard thresholds (simulate FPS series).
- Score & star band calculations.
- Telemetry adapter routes events to console without throwing.
-
Integration (Playwright):
- Start → finish happy path; restart is instant.
- Two known seeds produce identical runs and scores.
- UI responsiveness: slider drag latency under threshold.
-
Visual checks: screenshot diff of HUD & gate legibility across DPRs (1.0/2.0/3.0).
- Project shape: single-page app; ES modules; no mandatory build step (can add bundler later).
- Assets: glTF (embedded) or inline BufferGeometry for ultra-light meshes; matcap PNGs (sRGB).
- Hosting: static hosting (GitHub Pages/Netlify/etc.).
- Shareable seed: via querystring; copy-to-clipboard button on end card (optional later).
- Documentation: Inline JSDoc on public APIs; keep architecture and tooling notes current in
README.md/docs/.
- Multiplayer, accounts, cloud saves.
- Heavy post-processing (DOF, motion blur, OutlinePass).
- Complex physics or per-soldier IK.
/src
/core // pure logic (rng, math, scores, perf, state)
/world // lane, props, gates (data + spawn policies)
/units // formations, combat, pooling
/render // three.js glue (composer, matcaps, instancing, trails)
/ui // DOM UI (slider, HUD, dialogs)
/game // state machine, wiring
/tests
/unit
/integration
/docs
implementation-progress.md
public/index.html // ESM entry, no bundler required (import maps optional)
Tooling & scripts (package.json)
"test": "jest --runInBand""test:watch": "jest --watch""lint": "eslint . --max-warnings=0""e2e": "playwright test --reporter=line""check": "npm run lint && npm run test && npm run e2e"
CI-friendly, non-interactive reporters; Node 20+, Jest + JSDOM, Playwright for e2e.
The following is an iteration-by-iteration TDD build plan that is bottom-up, one focused task per iteration, CI-friendly, and aligned with the locked spec. Each iteration lists goal, prep, tests-first items (with Given/When/Then + short “why this test matters”), implementation notes, and the exact commands to run. After every iteration, append a short note to docs/implementation-progress.md.
Goal: Deterministic seeds for gates/props/skirmish spread.
Prep: ripgrep to ensure no prior RNG.
Tests (unit):
- Given seed
1234, When generating 5 numbers, Then the sequence equals a stored snapshot. why this test matters: locks determinism across machines. - Given two RNGs with the same seed, When advanced in different batch sizes but same total draws, Then final value matches.
why: prevents subtle order bugs.
Impl notes: xorshift32 or mulberry32; exposes
nextFloat(),nextInt(min,max). Run:npm run test && npm run lintDoc: Add decisions & sequence snippet todocs/implementation-progress.md.
Goal: Central evaluator for +/-/×/÷ with clamping/rounding per spec.
Tests (unit):
applyGate(10, "+5") → 15,("-20") clamps to 0. why: correctness of additive rules.applyGate(10, "×1.5") → 15 (nearest),("÷3.2") → 3 (nearest, min 1). why: rounding consistency.- Never returns
< 1after ×/÷. why: gameplay safety. Impl notes: pure function; no side effects. Run:npm run test
Goal: Produce two valid gate choices with meaningful deltas per wave. Tests (unit):
- Never yields
÷0or a−that kills all units. why: fail-safe content. - Delta between options ≥15% (early waves) / ≥25% (later). why: decision salience.
- Deterministic for a given seed+wave. why: shareable seeds.
Run:
npm run test
Goal: Score formula + 1★/2★/3★ thresholds. Tests (unit):
- Perfect decisions + fast skirmishes reach ≥3★; sloppy reaches <2★ on same seed. why: curve feels right.
- Band values serialize and re-load correctly. why: stable end cards.
Run:
npm run test
Goal: Rolling FPS average + degrade/upgrade signals. Tests (unit):
- Given series below 50 FPS for 2s, Then emits
DEGRADE_STEP1. why: protects mobile perf. - Given ≥58 FPS for 4s, Then emits
UPGRADE. why: recovers visuals. Run:npm run test
Goal: Reusable pool for arrows/sparks. Tests (unit):
acquirereturns recycled instances afterrelease. why: GC stability.- Pool caps prevent growth beyond limit. why: predictable memory.
Run:
npm run test
Goal: Transform count → formation slots (grid arc) with pivot at (0,0,0). Tests (unit):
- 1, 10, 100 units produce non-overlapping positions. why: visual clarity.
- Formation width/height scales smoothly with count. why: camera framing.
Run:
npm run test
Goal: 150 ms volleys; casualties per spec; seedable spread. Tests (unit):
- Fixed attacker/defender counts + seed → deterministic time-to-kill. why: repeatable runs.
- Casualties never exceed current counts. why: integrity.
- “Fast win” vs “near parity” produce different tick lengths. why: pacing.
Run:
npm run test
Goal: Segment recycling, fog window 25→60 m. Tests (unit):
- Camera moving forward reuses segments without gaps/overlap. why: endless lane.
- Reverse direction mirrors reuse. why: chase beat support.
Run:
npm run test
Goal: Place two gates per decision, safe distances from pillars/centerline. Tests (unit):
- Gates never overlap; spacing before/after respects min distance. why: fairness & readability.
- Operator→color mapping correct. why: accessibility/consistency.
Run:
npm run test
Goal: Deterministic flags/cones/markers per 10 m; culling >65 m behind. Tests (unit):
- Seeded prop positions are reproducible; never intersect gates or lane center. why: stable scenery.
- Density ≈ 1 per 10 m over long run. why: visual rhythm.
Run:
npm run test
Goal: Prebaked Catmull-Rom samples for Forward/Skirmish/Reverse. Tests (unit):
- Sampling at t∈[0..1] returns continuous, monotonic path; lookAt lerp stable. why: jitter-free.
- Beat transitions respect durations (200–220 ms). why: timing.
Run:
npm run test
Goal: Accessible slider → normalized steer ∈ [−1, +1]. Tests (integration, Playwright):
- Drag/Touch adjusts value smoothly; keyboard A/D mirrors on desktop. why: control parity.
- Hit area ≥44 px; thumb enlarges while active. why: mobile ergonomics.
Run:
npm run e2e
Goal: Top-center score/timer; compact format; ±delta flash 250 ms. Tests (integration):
- Numbers align (tabular-nums); deltas animate and auto-clear. why: glanceable feedback.
- Pause hides timer, resume restores. why: state integrity.
Run:
npm run e2e
Goal: PreRun → Running → Skirmish → Reverse → EndCard transitions. Tests (unit):
- Given seed X, driving inputs across beats reaches EndCard without illegal transitions. why: flow safety.
- Restart returns to PreRun with clean state. why: instant retries.
Run:
npm run test
Goal: Renderer (antialias:false), FXAA in composer, gradient backdrop, fog.
Tests (integration/smoke):
- Canvas mounts; frame count > 0; gradient & fog uniforms applied. why: render pipeline sanity.
- Toggling low-perf flag disables bloom path (stub for now). why: future guard.
Run:
npm run e2e
Goal: Single InstancedMesh for player/enemy; matcap material.
Tests (integration/visual diff):
- Counts 1→100 render within formation bounds (screenshots diff threshold). why: layout fidelity.
- Material sRGB output toggled correctly. why: color correctness.
Run:
npm run e2e
Goal: Gate mesh (≤200 tris) + emissive numeral quad in bloom-include list. Tests (integration):
- Gate symbols color-coded; two choices visible and non-overlapping. why: legibility.
- Numeral cards flagged for bloom list (data check now; visual in next iteration). why: post-FX hookup.
Run:
npm run e2e
Goal: Add Bloom effect; include only numeral/arrow materials. Tests (integration/visual):
- Numeral quads glow; pillars do not; FXAA retained. why: attention focus.
- Fallback flag disables bloom cleanly. why: low-end mode.
Run:
npm run e2e
Goal: Ultra-Lean: 6 segments, 220 ms lifetime; sparks burst on casualties. Tests (integration):
- Max emitters capped; lifetime respected; object pool reuse verified (counter). why: perf ceiling.
- Trail materials on bloom include list only. why: effect budget.
Run:
npm run e2e
Goal: Drive volleys from combat ticks; damage flash (emissive pop). Tests (integration):
- Known seed results in expected volley count & duration; end state matches unit tests. why: logic/render parity.
- Flash intensity animates 0→0.8→0 over 140 ms. why: feedback timing.
Run:
npm run e2e
Goal: Spawn chase horde at D0; speed vChase=1.05×; win/lose. Tests (integration):
- With low player count, fail condition triggers before finish; with high, succeed. why: balance envelope.
- Transition adds snap-zoom +1.5% FOV. why: beat emphasis.
Run:
npm run e2e
Goal: Flags, cones, track markers with placement rules & CPU early-out. Tests (integration):
- Density ~1/10 m over 300 m lane; no intersections with gates/lane center. why: spatial rules.
- Frustum + “>65 m behind” culling active (counter stats). why: perf.
Run:
npm run e2e
Goal: Show score, 1–3★ bands; Restart resets instantly; share seed link. Tests (integration):
- Perfect path seed ≥3★; sloppy ≤2★ (using fixed run). why: reward curve.
- Restart clears transient state (pools, counts, timers). why: replay loop.
Run:
npm run e2e
Goal: Wire FPS monitor to degrade/upgrade VFX. Tests (integration):
- Simulated low FPS → trails segments halve, sparks off, bloom res reduced; recovery restores. why: mobile resilience.
Run:
npm run e2e
Goal: Color contrast ≥ 4.5:1 HUD; operator symbols alongside colors; pause/resume clarity. Tests (integration):
- Automated contrast check for HUD foreground vs gradient (threshold). why: readability.
- Gate readability snapshot tests across DPR 1.0/2.0/3.0. why: device coverage.
Run:
npm run e2e
-
Pre-check:
rg "<keyword>" -nto avoid re-implementing existing logic; refactor if present. -
Write tests first (unit or e2e). Include a brief
// why this test matters: ...comment. -
Implement minimal code to pass tests.
-
Run checks:
npm run check(lint + unit + e2e). -
Docs update: append to
docs/implementation-progress.md:- What changed, why it matters, decisions, open questions, next iteration.
-
Commit message:
feat(core|world|ui|render): <iteration title> [#iteration-XX].
Unit (Jest):
// tests/unit/gates.math.test.ts
// why this test matters: rounding and clamps underpin all counts; one mismatch cascades into wrong difficulty.
test("× and ÷ rounding & clamps", () => {
expect(applyGate(10, {op:"mul", val:1.5})).toBe(15);
expect(applyGate(10, {op:"div", val:3.2})).toBe(3);
expect(applyGate(1, {op:"div", val:3.2})).toBe(1);
});Integration (Playwright):
// tests/integration/hud.delta.spec.ts
// why this test matters: players must read gains/losses instantly; animation regressions are common.
test("delta flashes for 250ms then clears", async ({ page }) => {
await page.goto("/public/index.html?seed=test-seed");
await startRun(page);
await chooseGate(page, "mul", 1.5);
const delta = page.getByTestId("hud-delta");
await expect(delta).toBeVisible();
await page.waitForTimeout(300);
await expect(delta).toBeHidden();
});- One task per iteration. Keep PRs small and focused.
- TDD: tests first; minimal implementation; re-run
npm run checkbefore/after. - “Why this test matters” comment in every new/changed test.
- Search before implement. If functionality exists, prefer refactor over duplication.
- Append learnings to
docs/implementation-progress.mdafter each iteration (serves as project memory & devlog). - CI-friendly: avoid interactive prompts; stable output; use line reporters.
- Performance budgets: draw calls < 150; active particles ≤ ~250; watch dev HUD (optional) that prints counters.