Files
iron-requiem/docs/PHASE_I_II_IMPLEMENTATION_PLAN.md
Kay Kayyali 4bef8e66df
Some checks failed
Iron Requiem CI/CD / test (push) Failing after 11s
Iron Requiem CI/CD / deploy (push) Has been skipped
Build & Deploy / build-and-deploy (push) Has been cancelled
Fix Recovery Phase 4: Single bundle script, correct Traefik network
- build.sh: Remove old bundle tag before injecting hashed version
- index.html: Remove duplicate script tag from template
- docker-compose.yml: Fix network name (hermes-net, not litellm_hermes-net)
- Deployment verified: HTTPS 200 via Cloudflare + Traefik
2026-05-24 04:30:06 +00:00

25 KiB
Raw Permalink Blame History

Phase I+II Gap Implementation Plan — Iron Requiem

This plan addresses the five playability gaps identified in the Phase I+II assessment. Each gap includes files, integration points, tests, and dependencies.

Last updated: 2026-05-23


Gap Summary & Execution Order

# Gap Complexity Depends On Can Parallel With
2 Dynamic Vision Mask S 1, 3, 5
5 SFX/VFX (Audio + Flash) L 1, 2, 3
1 HUD Feedback M 2, 3, 5
3 Collision/Damage Loop M 1, 2, 5
4 Hatch Animation/Audio M 5 (audio system)
Week 1:  Gap 2 (S)  ←────────→  Gap 5 Phase A (audio system foundation)
                                 Gap 1 (M) parallel start
                                 Gap 3 (M) parallel start

Week 2:  Gap 5 Phase B (VFX muzzle/impact) + Gap 1 (HUD wiring)
         Gap 3 (collision overlap wiring)

Week 3:  Gap 4 (hatch animation + audio cues, depends on Gap 5 audio)
         Integration polish — all five gaps verified together

Rationale: Gap 2 is the quickest win (modify 2 files, no new files) and demonstrates immediate progress. Gap 5 (audio + VFX) is the longest pole — start its audio foundation early so Gap 4's audio cues aren't blocked. Gaps 1, 2, 3 are all independent and can run in parallel. Gap 4 must wait on Gap 5's audio manager.


Gap 1: HUD Feedback

Complexity: M (Medium) — 2 new files, 1 modified

Current State

  • AmmoSystem tracks inventory, fires onWarning callback, exposes getInventory() and getTotalRemaining().
  • MainGame routes onWarning to console only (line 84-86).
  • HeatSystem and CrewManager don't exist yet (Slice 3 scope), but HUD needle slots must be ready.
  • No visual ammo count, shell type indicator, heat gauge, or morale gauge visible to player.

Desired State

Player sees diegetic needle gauges (fuel, heat, morale) and a shell indicator strip (current shell type highlighted, remaining count per type). All rendered as Phaser Graphics objects in a dedicated HUDScene overlay.

Files to Create

File Purpose
src/game/ui/DiegeticHUD.js Pure logic class — computes needle angles from 0-100 values, formats ammo display text, holds gauge state. No Phaser dependency (testable standalone).
src/game/scenes/HUDScene.js Phaser scene overlay — reads DiegeticHUD state each frame, draws Graphics-based needles, shell icons, ammo count. Launched parallel to MainGame via this.scene.launch().

Files to Modify

File Change
src/game/scenes/MainGame.js 1. Instantiate DiegeticHUD in create(). 2. Launch HUDScene in create(). 3. Update HUD data each tick in update(): pass ammoSystem.getInventory(), ammoSystem.getTotalRemaining(), ammoSystem.getActiveShell(). 4. Route onWarning to HUD instead of console.

Integration Points

MainGame.update()
  → diegeticHUD.updateAmmo(ammoSystem.getInventory(), ammoSystem.getActiveShell())
  → diegeticHUD.updateHeat(0)    // placeholder until HeatSystem exists
  → diegeticHUD.updateMorale(50)  // placeholder until CrewManager exists
  → HUDScene reads diegeticHUD state each frame

HUDScene runs as a parallel scene (overlay on top of MainGame), depth 200+. It reads DiegeticHUD state via this.scene.get('MainGame').diegeticHUD.

Testing Strategy

Unit tests (Jest): tests/ui/DiegeticHUD.test.js

  • Pure logic: computeNeedleAngle(0) returns -45°, computeNeedleAngle(100) returns +45°
  • Ammo warning thresholds: isLowAmmo(total) returns true at 10, 5, 0
  • Shell display string: formatAmmoDisplay({apcbc: 87, apcr: 6, he: 20, heat: 10}) returns 4 formatted lines
  • No Phaser dependency — test class directly

Integration tests (Jest): tests/integration/hud-wiring.test.js

  • Verify MainGame.create() instantiates DiegeticHUD and launches HUDScene
  • Verify onWarning callback routes to HUD (mock HUD, assert showWarning() called)
  • Verify HUD state updates correctly after ammoSystem.fire() reduces count

E2E tests (Playwright): tests/e2e/hud.spec.js

  • Load https://iron-requiem.damascusfront.net
  • Verify ammo text element exists and displays "APCBC: 87"
  • Press key 2 (APCR) → verify HUD shows APCR highlighted
  • Design review: game-designer opens browser, confirms needles are readable at 2x/3x scale

Gap 2: Dynamic Vision Mask

Complexity: S (Small) — modify 2 files, extend existing tests

Current State

  • VisionMask uses hardcoded RANGE = 200 and HALF_ARC = 200 constants (VisionMask.js lines 14-15).
  • VisionMask.update(x, y, angle) accepts position/rotation but NOT hatch state.
  • CommanderHatch.getVisionMask() returns {arc, range} based on buttoned/unbuttoned state.
  • MainGame.update() calls this.commanderHatch.update(inputState) first, then this.visionMask.update(x, y, angle) — but the hatch result is never passed to the mask.

Desired State

When player presses E to unbutton, the periscope hole expands from 180° to 270° arc and range extends from 200px to 350px. The transition is instant on toggle (animation belongs to Gap 4).

Files to Modify

File Change
src/game/systems/VisionMask.js 1. Change RANGE and HALF_ARC from top-level const to instance properties (this._range, this._halfArc) initialized to buttoned defaults. 2. Add setArc(arcDegrees, rangePx) public method. 3. _computeHoleCorners() and isVisible() read this._range / this._halfArc instead of module constants.
src/game/scenes/MainGame.js In update(): after commanderHatch.update(), call const vm = this.commanderHatch.getVisionMask(); this.visionMask.setArc(vm.arc, vm.range) before visionMask.draw().

Integration Points

MainGame.update() each frame:
  1. commanderHatch.update(inputState)     // toggles isUnbuttoned on E press
  2. const vm = commanderHatch.getVisionMask()  // {arc: 270, range: 350} or {arc: 180, range: 200}
  3. visionMask.setArc(vm.arc, vm.range)   // updates internal arc/range
  4. visionMask.update(x, y, angle)         // updates position/rotation
  5. visionMask.draw()                      // renders with dynamic arc

Testing Strategy

Unit tests (Jest): Extend tests/systems/VisionMask.test.js

  • setArc(270, 350) then isVisible() — point at 250px range, 0° angle → visible (in range)
  • setArc(180, 200) then isVisible() — same point → not visible (outside range)
  • Edge case: setArc(0, 0) — everything outside mask
  • Verify getPeriscopeEdgePoints() extends to 350px range after setArc(270, 350)
  • Performance: setArc() + draw() within 16ms budget (existing performance test covers)

Integration tests (Jest): Extend tests/integration/slice1-wiring.test.js

  • Simulate E-key toggle → verify visionMask arc transitions from 180 to 270
  • Verify vision mask arc returns to 180 when toggled back

E2E tests (Playwright): tests/e2e/vision-mask.spec.js

  • Load game, press E, observe that more of the screen becomes visible (larger hole)
  • Game-designer verifies the expanded vision is visually distinct and functional

Gap 3: Collision/Damage Loop

Complexity: M (Medium) — modify 1 file (MainGame.js), create 1 test file

Current State

  • PatternManager spawns enemy projectiles into this.enemyProjectileGroup (Arcade physics group).
  • Enemy is a plain JS class with takeDamage(amount) and hp — no Arcade body.
  • Projectile is a plain JS class with onHit(target) that computes penetration/damage.
  • CommanderHatch has isHitboxActive(), takeHit(), beginSniperShot(), completeSniperShot() but nothing calls them.
  • MainGame creates projectileGroup and enemyProjectileGroup but never sets up overlap callbacks.
  • No player fire mechanism is wired — no mouse-click or spacebar handler.

Desired State

  1. Player fires shells: Mouse click (left button) → ammoSystem.fire() → create Projectile → spawn a sprite in projectileGroup moving at shell velocity toward turret angle.
  2. Player projectiles hit enemies: Distance-based check (enemies are plain objects) in update(). On hit: enemy.takeDamage(projectile.onHit(enemy).damage).
  3. Enemy projectiles hit tank: Physics overlap callback this.physics.overlap(this.enemyProjectileGroup, this.tank, onEnemyProjectileHitTank). On hit: destroy projectile sprite, damage tank, check hatch hitbox.
  4. Enemy projectiles hit player projectiles: Optional — cancels both (shell interception). Nice-to-have for Phase I+II, not critical.

Files to Modify

File Change
src/game/scenes/MainGame.js 1. Add this.input.on('pointerdown', onFire) handler in create(). 2. Implement onFire(): get active shell from ammoSystem.fire(), create Projectile object, spawn sprite in projectileGroup at tank position toward turret angle. 3. In create(): this.physics.overlap(this.enemyProjectileGroup, this.tank, this._onEnemyProjHitTank, null, this). 4. In update(): iterate projectileGroup active sprites, check distance to each enemy (in this.enemies), resolve hits. 5. Track tank HP and destroy/restart on death.

New Methods on MainGame

_onFire() {
  // 1. Get shell config from ammoSystem.fire()
  // 2. Create Projectile(tank.x, tank.y, turret.getWorldAngle(), shellConfig)
  // 3. Spawn sprite in projectileGroup at that position/angle with shell velocity
  // 4. Track projectile object on sprite for hit resolution
}

_onEnemyProjHitTank(tankSprite, projSprite) {
  // 1. Deactivate/destroy projectile sprite
  // 2. Check commanderHatch.isHitboxActive()
  // 3. If unbuttoned: commanderHatch.takeHit(), morale penalty
  // 4. Apply tank damage
  // 5. Screen shake via this.cameras.main.shake(100, 0.01)
}

_checkPlayerProjectileHits(delta) {
  // For each active player projectile sprite:
  //   For each active enemy:
  //     If distance(proj, enemy) < hitRadius:
  //       const result = projectile.onHit(enemy)
  //       enemy.takeDamage(result.damage)
  //       projectile.alive = false
  //       despawn sprite
}

Integration Points

Mouse click → AmmoSystem.fire() → spawn Projectile sprite in projectileGroup
                                  ↓
MainGame.update() → _checkPlayerProjectileHits() → Enemy.takeDamage()
                  → physics.overlap callback → Tank damage, Hatch hitbox
                  → CommanderHatch.takeHit() (if unbuttoned)

Testing Strategy

Unit tests (Jest): tests/slice2_collision.test.js

  • Projectile.onHit(enemy) computes correct damage for each shell type vs armor
  • Enemy.takeDamage(50) reduces hp to 200 (from 250 default? actually Type 59 has 300 hp)
  • CommanderHatch.takeHit() sets isUnbuttoned = false, applies morale penalty, starts cooldown
  • Fire rate limiting: ammoSystem.fire() called twice in 100ms → second call returns {type:'ram'} if shell count = 0 after first

Integration tests (Jest): tests/integration/collision-wiring.test.js

  • Mock scene: create MainGame, spawn one enemy, fire one projectile → after 100ms update loop, enemy HP decreased
  • Enemy projectile hitting tank: simulate overlap → verify tank damage callback fires
  • Unbuttoned tank hit → hatch takeHit() called, isUnbuttoned becomes false

E2E tests (Playwright): tests/e2e/collision.spec.js

  • Game-designer fires at enemy, observes HP drop (via debug overlay or visual feedback)
  • Enemy projectile hits tank, observes screen shake

Gap 4: Hatch Animation/Audio

Complexity: M (Medium) — modify 2 files. Depends on Gap 5 (audio system).

Current State

  • E-key toggle works (state changes in CommanderHatch) but produces zero visual or audio feedback.
  • No hatch animation — no sliding motion, no camera bob, no visual indicator of state.

Desired State

  • Visual: When unbuttoning, a "hatch open" overlay graphic animates (dark circle slides open from center). Camera subtly bobs up (~5px) then settles.
  • Audio: Wind rush sound when unbuttoned (ambient). Muffled, quieter ambient when buttoned. Audio transition crossfades over 500ms.
  • Telegraph: When CommanderHatch.beginSniperShot() is called (from Gap 3 collision), a red laser line paints the turret on screen, visible for 2s charge time.

Files to Modify

File Change
src/game/entities/CommanderHatch.js Add event emitter or callback hooks: onToggle(isUnbuttoned), onSniperTelegraph(active), onSniperComplete(). MainGame registers listeners.
src/game/scenes/MainGame.js Register hatch event handlers: 1. onToggle → run camera bob tween + trigger audio crossfade. 2. onSniperTelegraph → draw red laser line from enemy position to tank turret. 3. onSniperComplete → remove laser line, screen flash red.

Hatch Visual Implementation

Use Phaser Graphics for hatch overlay (dark circle vignette that contracts/expands):

// In MainGame, on hatch toggle:
if (isUnbuttoned) {
  // Tween hatch overlay alpha from 1→0 (open)
  this.tweens.add({ targets: this.hatchOverlay, alpha: 0, duration: 400 });
  // Camera bob: up 5px, back down
  this.cameras.main.shake(200, 0.003);
} else {
  // Tween hatch overlay alpha from 0→1 (close)
  this.tweens.add({ targets: this.hatchOverlay, alpha: 1, duration: 400 });
}

Audio Implementation (requires Gap 5 AudioManager)

// Gap 5 provides this.audioManager
if (isUnbuttoned) {
  this.audioManager.crossfade('wind_ambient', 'engine_muffled', 500);
} else {
  this.audioManager.crossfade('engine_muffled', 'wind_ambient', 500);
}

Testing Strategy

Unit tests (Jest): tests/game/entities/CommanderHatch.events.test.js

  • toggle() fires onToggle(true) when opening — event callback receives correct state
  • beginSniperShot() fires onSniperTelegraph(true) — callback called
  • completeSniperShot() fires onSniperComplete() — callback called
  • Edge: toggling while on cooldown does NOT fire onToggle

Integration tests (Jest): tests/integration/hatch-animation.test.js

  • MainGame registers onToggle handler → verifies tween targets set correctly
  • Sniper telegraph → verifies laser graphics drawn at correct position
  • Mock audioManager → verifies crossfade called with correct clip names

E2E tests (Playwright): tests/e2e/hatch.spec.js

  • Game-designer presses E → visually confirms hatch overlay fades and camera bobs
  • Sniper scenario: unbutton, wait → red laser appears → 2s later screen flash

Gap 5: SFX/VFX

Complexity: L (Large) — 3-4 new files. Foundational for Gap 4 audio.

Current State

  • Zero audio. No AudioContext, no sound manager, no audio assets.
  • Zero VFX. No muzzle flashes, shell impacts, engine drone, radio barks, screen effects.
  • __mocks__/phaser.js and tests/helpers/setup.js already stub AudioContext (setup.js line 82-87).

Desired State

  • Audio sprite system: Procedurally generated short sound effects (white noise bursts for impacts, oscillator sweeps for engine drone, static bursts for radio). No external audio files needed for Phase I+II demo.
  • Muzzle flash VFX: When player fires, a brief bright rectangle appears at turret tip (turret position + forward offset), fading over 100ms.
  • Impact VFX: When enemy projectile hits tank or player projectile hits enemy, a brief orange/white particle burst at impact point.
  • Engine drone: Continuous low-frequency oscillator that shifts pitch with tank speed.
  • Radio barks: Triggered on enemy spawn or ammo warning — brief static burst + synthesized voice-like tone pattern.

Files to Create

File Purpose
src/game/systems/AudioManager.js Web Audio API wrapper. Manages AudioContext, creates procedural sounds (oscillator, noise buffer), provides play(soundId, volume, pitch), startLoop(soundId, params), stopLoop(soundId), crossfade(fromId, toId, durationMs). All sounds are procedurally generated — no asset loading required for Phase I+II.
src/game/systems/VFXManager.js Phaser Graphics-based flash/particle effects. muzzleFlash(x, y, angle) draws bright rectangle + fade tween. impactBurst(x, y, color) draws expanding circle + alpha tween. screenFlash(color, duration) full-screen overlay tween.
src/game/data/audio-defs.js Procedural sound definitions: frequency ranges, noise types, envelope shapes for each sound ID (muzzle_report, impact_metal, engine_loop, radio_static, sniper_laser_tone, wind_ambient).

Files to Modify

File Change
src/game/scenes/MainGame.js 1. Initialize AudioManager and VFXManager in create(). 2. On fire: vfxManager.muzzleFlash() + audioManager.play('muzzle_report'). 3. On projectile hit enemy: vfxManager.impactBurst() + audioManager.play('impact_metal'). 4. On enemy projectile hit tank: vfxManager.screenFlash(0xff0000, 100) + audioManager.play('impact_metal'). 5. Engine drone: audioManager.startLoop('engine_loop', {speed}) updated in update().

AudioManager Design

class AudioManager {
  constructor() {
    this.ctx = new (window.AudioContext || window.webkitAudioContext)();
    this.masterGain = this.ctx.createGain();
    this.masterGain.connect(this.ctx.destination);
    this._loops = {};  // active looping sounds
    this._defs = {};   // loaded sound definitions
  }

  // Play a one-shot procedural sound
  play(soundId, options = {}) { /* ... */ }

  // Start/update looping sound (engine drone)
  startLoop(soundId, params = {}) { /* ... */ }
  stopLoop(soundId) { /* ... */ }

  // Crossfade from one loop to another (hatch open/close)
  crossfade(fromId, toId, durationMs) { /* ... */ }

  // Procedural generators
  _createNoiseBuffer(duration) { /* white noise */ }
  _createOscillator(freq, type) { /* sine/sawtooth/square */ }
}

Procedural sound palette for Phase I+II:

Sound ID Generation Envelope
muzzle_report White noise burst 80ms + 200Hz sawtooth 100ms Attack 1ms, decay 150ms
impact_metal White noise burst 50ms + 800Hz sine ping 30ms Attack 0, decay 80ms
engine_loop 60Hz sawtooth + 120Hz square, low-pass filtered Continuous, pitch modulated by speed
radio_static White noise, band-pass filtered 1000-3000Hz 500ms burst, 200ms fade
sniper_laser_tone 2000Hz sine, high Q 2s ramp up in volume
wind_ambient Filtered white noise, low frequency Continuous loop

VFXManager Design

class VFXManager {
  constructor(scene) {
    this.scene = scene;
    this.graphics = scene.add.graphics();
    this.graphics.setDepth(500); // above HUD
  }

  muzzleFlash(x, y, angle) {
    // Draw bright yellow/white rectangle at offset
    // Tween alpha 1→0 over 100ms, then clear
  }

  impactBurst(x, y, color = 0xff8800) {
    // Draw expanding circle + radial lines
    // Tween scale + alpha over 200ms
  }

  screenFlash(color = 0xff0000, duration = 100) {
    // Full-screen fill rectangle, tween alpha 1→0
  }

  update() {
    // Called each frame — advance active tweens
  }
}

Integration Points

Fire event    → VFXManager.muzzleFlash() + AudioManager.play('muzzle_report')
Impact event  → VFXManager.impactBurst() + AudioManager.play('impact_metal')
Tank hit      → VFXManager.screenFlash() + AudioManager.play('impact_metal')
Hatch toggle  → AudioManager.crossfade('wind_ambient', 'engine_muffled')
Engine update → AudioManager.startLoop('engine_loop', {pitch: speedRatio})
Enemy spawn   → AudioManager.play('radio_static') [optional — Phase I+II nice-to-have]

Testing Strategy

Unit tests (Jest): tests/systems/AudioManager.test.js

  • AudioManager.play('muzzle_report') creates oscillator + noise nodes, connects, starts, stops
  • AudioManager.startLoop('engine_loop') creates continuous oscillator, updates pitch in real-time
  • AudioManager.crossfade('wind', 'engine', 500) creates gain ramp on both loops
  • AudioManager gracefully degrades when AudioContext is unavailable (no crash, no-op)
  • Memory: 100 play() calls don't leak (nodes properly disconnected after envelope completes)

Unit tests (Jest): tests/systems/VFXManager.test.js

  • muzzleFlash(x, y, angle) creates Graphics objects at correct position/orientation
  • impactBurst(x, y, color) creates expanding circle effect
  • screenFlash(0xff0000, 100) creates overlay + tween
  • VFX cleanup: old effects don't accumulate (Graphics.clear() called after tween completes)

Integration tests (Jest): tests/integration/sfx-vfx-wiring.test.js

  • Mock scene fire → verify VFXManager.muzzleFlash + AudioManager.play called
  • Mock collision → verify VFXManager.impactBurst called at correct coordinates

E2E tests (Playwright): tests/e2e/sfx-vfx.spec.js

  • Game-designer fires weapon → sees muzzle flash, hears report
  • Enemy projectile hits tank → sees impact burst, screen flash
  • Subjective: engine drone pitch changes with tank speed

Cross-Cutting Concerns

Playwright E2E Test Setup

All gaps need Playwright E2E tests so the game-designer can review mechanics in a real browser.

Setup: tests/e2e/ directory with Playwright config pointing to https://iron-requiem.damascusfront.net.

// tests/e2e/playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
  testDir: './tests/e2e',
  use: {
    baseURL: 'https://iron-requiem.damascusfront.net',
    viewport: { width: 1280, height: 720 },
  },
});

Pattern per gap:

  1. Navigate to game
  2. Wait for Phaser canvas to exist (canvas selector)
  3. Inject script to read game state: window.__IR_GAME.scene.scenes[1] (MainGame)
  4. Simulate inputs via window.dispatchEvent(new KeyboardEvent('keydown', {key: 'E'}))
  5. Assert visual state via canvas screenshot or injected state read

Install: npm install --save-dev @playwright/test (added to package.json devDependencies).

Jest Test Pattern

All unit tests follow existing patterns:

  • VisionMask: dependency-injected Graphics mock (already established)
  • DiegeticHUD: pure logic class, no Phaser dependency
  • AudioManager: mock AudioContext via tests/helpers/setup.js (already stubs AudioContext class)
  • VFXManager: dependency-injected Graphics mock + Phaser tween mock

All tests use test() / describe() / expect() from Jest globals. No import from 'vitest'.

Code Quality Gates (per gap)

Each gap is complete when:

  1. npm test passes all tests (existing 43 + new gap tests, minimum 85% line coverage for new code)
  2. npm run build succeeds (webpack production build)
  3. Docker container builds and serves the updated game
  4. Playwright E2E tests pass against live site
  5. Game-designer signs off via browser review

Performance Budget (all gaps combined)

  • VisionMask draw: < 1ms (already benchmarked at < 16ms avg for 100 iterations)
  • HUD redraw: < 2ms per frame (4 needles + ammo text, Graphics-based)
  • Collision check: < 3ms per frame (max 50 enemies × 50 projectiles = 2500 distance checks — O(1) math per check)
  • Audio: non-blocking (Web Audio API runs on separate thread)
  • VFX: < 2ms per frame (max 5 simultaneous effects, Graphics tweens)
  • Total frame budget impact: < 8ms (12.5% of 60fps frame), well within budget

Implementation Task Breakdown (for Kanban)

Task Gap Assignee Est. Hours Dependencies
Vision mask dynamic arc wiring 2 dev 1
AudioManager procedural system 5 dev 4
VFXManager muzzle/impact/screen 5 dev 3
Wire sfx/vfx into MainGame fire + collision 5 dev 2 AudioManager, VFXManager
DiegeticHUD logic class 1 dev 2
HUDScene Phaser overlay 1 dev 2 DiegeticHUD
Wire HUD into MainGame 1 dev 1 HUDScene, DiegeticHUD
Collision: player fire mechanism 3 dev 2
Collision: overlap + distance checks 3 dev 3 player fire
Hatch event hooks + animation wiring 4 dev 3 AudioManager
Playwright E2E setup + per-gap specs all dev 3 all gaps functional
Integration polish + full-suite verification all dev 2 all tasks complete

Total estimated: ~28 hours (4-5 days at sustainable pace)


Risk Register

Risk Likelihood Impact Mitigation
AudioContext blocked by browser autoplay policy High Medium Resume AudioContext on first user click (Phaser input event). Already handled in constructor pattern: lazy-init ctx on first play().
Procedural audio sounds bad/subjective Medium Low Keep AudioManager interface abstracted — easy to swap in real audio files later. Phase I+II only needs "functional" audio, not polished.
Collision performance with 200+ projectiles Low Medium Use spatial hashing if simple distance check is too slow. Start with O(n×m), profile, optimize only if needed.
HUD readability at 640×360 on 1080p screen (3x scaling) Medium Medium Test at 2x and 3x scale. Needles must be at least 4px wide after scaling. Use setDepth to ensure crisp rendering.
Playwright tests flaky due to Phaser async boot Medium Low Use robust selectors: wait for canvas element, then page.evaluate(() => window.__IR_GAME !== undefined). Retry with 3x attempts.