- 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
25 KiB
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) | — |
Recommended Execution Order
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
AmmoSystemtracks inventory, firesonWarningcallback, exposesgetInventory()andgetTotalRemaining().MainGameroutesonWarningto console only (line 84-86).HeatSystemandCrewManagerdon'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()instantiatesDiegeticHUDand launchesHUDScene - Verify
onWarningcallback routes to HUD (mock HUD, assertshowWarning()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
VisionMaskuses hardcodedRANGE = 200andHALF_ARC = 200constants (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()callsthis.commanderHatch.update(inputState)first, thenthis.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)thenisVisible()— point at 250px range, 0° angle → visible (in range)setArc(180, 200)thenisVisible()— same point → not visible (outside range)- Edge case:
setArc(0, 0)— everything outside mask - Verify
getPeriscopeEdgePoints()extends to 350px range aftersetArc(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
visionMaskarc 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
PatternManagerspawns enemy projectiles intothis.enemyProjectileGroup(Arcade physics group).Enemyis a plain JS class withtakeDamage(amount)andhp— no Arcade body.Projectileis a plain JS class withonHit(target)that computes penetration/damage.CommanderHatchhasisHitboxActive(),takeHit(),beginSniperShot(),completeSniperShot()but nothing calls them.MainGamecreatesprojectileGroupandenemyProjectileGroupbut never sets up overlap callbacks.- No player fire mechanism is wired — no mouse-click or spacebar handler.
Desired State
- Player fires shells: Mouse click (left button) →
ammoSystem.fire()→ createProjectile→ spawn a sprite inprojectileGroupmoving at shell velocity toward turret angle. - Player projectiles hit enemies: Distance-based check (enemies are plain objects) in
update(). On hit:enemy.takeDamage(projectile.onHit(enemy).damage). - 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. - 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 armorEnemy.takeDamage(50)reduces hp to 200 (from 250 default? actually Type 59 has 300 hp)CommanderHatch.takeHit()setsisUnbuttoned = 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,isUnbuttonedbecomes 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()firesonToggle(true)when opening — event callback receives correct statebeginSniperShot()firesonSniperTelegraph(true)— callback calledcompleteSniperShot()firesonSniperComplete()— 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.jsandtests/helpers/setup.jsalready stubAudioContext(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, stopsAudioManager.startLoop('engine_loop')creates continuous oscillator, updates pitch in real-timeAudioManager.crossfade('wind', 'engine', 500)creates gain ramp on both loopsAudioManagergracefully 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/orientationimpactBurst(x, y, color)creates expanding circle effectscreenFlash(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:
- Navigate to game
- Wait for Phaser canvas to exist (
canvasselector) - Inject script to read game state:
window.__IR_GAME.scene.scenes[1](MainGame) - Simulate inputs via
window.dispatchEvent(new KeyboardEvent('keydown', {key: 'E'})) - 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:
npm testpasses all tests (existing 43 + new gap tests, minimum 85% line coverage for new code)npm run buildsucceeds (webpack production build)- Docker container builds and serves the updated game
- Playwright E2E tests pass against live site
- 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. |