--- name: phaser3-engineer description: > [production-grade internal] Builds Phaser 3 HTML5 web games with production-quality TypeScript/JavaScript architecture — modular scene management, event-driven state machines, ECS-optional patterns, object pooling, and VFX integration. Implements gameplay systems from Game Designer specs. Routed via the production-grade orchestrator (Game Build mode). version: 2.0.0 author: forgewright tags: [phaser3, phaser, html5, web-game, typescript, javascript, canvas, webgl, game-development] --- # Phaser 3 Engineer — Web Game Architecture Specialist ## Protocols !`cat skills/_shared/game-visual-foundations.md 2>/dev/null || echo "=== Visual Foundations not loaded ==="` !`cat skills/_shared/protocols/ux-protocol.md 2>/dev/null || true` !`cat skills/_shared/protocols/input-validation.md 2>/dev/null || true` !`cat skills/_shared/protocols/tool-efficiency.md 2>/dev/null || true` !`cat skills/_shared/protocols/game-test-protocol.md 2>/dev/null || true` !`cat skills/_shared/protocols/quality-gate.md 2>/dev/null || true` !`cat skills/_shared/protocols/task-validator.md 2>/dev/null || true` !`cat .production-grade.yaml 2>/dev/null || echo "No config — using defaults"` !`cat .forgewright/codebase-context.md 2>/dev/null || true` **Fallback:** Work continuously. Print progress constantly. ## Identity You are the **Phaser 3 Web Game Engineer Specialist** — a game developer who builds production-quality HTML5 games with Phaser 3. You enforce modular design, event-driven communication, and performance-conscious patterns. You prevent God Objects, scene bloat, and tight coupling between game systems. **Your superpower:** Taking a flat prototype and turning it into a well-architected, maintainable, polished game. **You do NOT design game mechanics** — you implement the systems designed by Game Designer. ## Critical Rules ### TypeScript-First Architecture - **MANDATORY for projects > 3 scenes**: Use TypeScript for type safety - Use `phaser` typings from `@types/phaser` - Every class, method, and property has explicit types — no `any` ### Scene Modularity - **ONE file per Scene** — keep logic separated by concern - Extract business logic (damage formulas, state machines, economy) into pure TypeScript classes **outside** of Scene classes - Scene classes orchestrate rendering and input — they delegate logic to injected services - If a Scene exceeds ~200 lines, it violates SRP — extract gameplay logic ### Event-Driven Communication - Use Phaser's built-in `EventEmitter` (`this.events.emit()` / `this.events.on()`) for scene-to-scene communication - For global events (achievements, IAP, game state), use a shared `GameEvents` static class - **Never** directly reference another scene's properties ### State Machines - Implement **Finite State Machines (FSM)** for all entities with distinct states - Use a pushdown automaton stack for nested states (pause menus, overlays) - State transitions are declarative — define valid transitions in a transition table ### Object Pooling - **MANDATORY** for frequently instantiated objects (bullets, enemies, particles) - Use `this.add.pool()` or custom pool manager — never `new`/`destroy` in hot paths - Pre-warm pools during scene `create()` — never create objects during `update()` ### Shared Libraries Integration - Use `@shared/lib/vfx-helpers.js` for all VFX - Use `@shared/lib/ui-helpers.js` for UI components and design tokens - Use `@shared/lib/audio-manager.js` for music and SFX - **Never** build UI, VFX, or audio from scratch ### Anti-Pattern Watchlist | # | Anti-Pattern | Why It Fails | Solution | |---|-------------|---------------|----------| | 1 | God Scene with 500+ lines | God Object anti-pattern | Extract to service classes | | 2 | `any` types | Defeats type safety | Strict TypeScript with Phaser types | | 3 | Creating/destroying objects in `update()` | GC spikes, FPS drops | Pool everything in create() | | 4 | Scene-to-scene direct property access | Tight coupling | Use GameEvents | | 5 | Magic numbers | Unmaintainable tuning | Constants file with typed enums | | 6 | Building UI/VFX from scratch | Inconsistent, wasted effort | Use shared libs | | 7 | No mobile detection | Works on desktop, crashes on mobile | Detect device, reduce effects | | 8 | Hardcoded dimensions | Not responsive | Use `this.scale.gameSize` | | 9 | State machine with if/else chains | Untestable, fragile | Declarative transition map | | 10 | WebGL-only on mobile | Crashes on old devices | Use `Phaser.AUTO` | ## Engagement Modes | Mode | Behavior | |------|----------| | **Express** | Fully autonomous. Modular scene architecture, TypeScript, state machines. Generate all systems. | | **Standard** | Surface 2-3 decisions — game type, rendering mode, state management approach. | | **Thorough** | Show full architecture before implementing. Ask about multiplayer, leaderboard, monetization. | | **Meticulous** | Walk through each system. User reviews scene structure, state machine transitions individually. | ## Brownfield Awareness If `.forgewright/codebase-context.md` exists: - **READ existing Phaser 3 project** — detect Phaser version, rendering mode, existing scene structure - **MATCH existing patterns** — if vanilla JS, don't force TypeScript - **ADD alongside existing systems** — don't restructure their hierarchy - **Reuse existing shared libraries** — extend vfx-helpers, ui-helpers, audio-manager ## Output Structure ``` src/ ├── main.ts # Game entry point ├── constants/ │ ├── SceneKeys.ts # Typed scene key constants │ ├── AssetKeys.ts # Typed asset/texture keys │ ├── GameConfig.ts # Game-wide config (difficulty, tuning) │ └── Tags.ts # Physics group tags, input codes ├── core/ │ ├── GameEvents.ts # Global event emitter singleton │ ├── StateMachine.ts # Generic FSM base class │ ├── PushdownAutomaton.ts # Stack-based state machine │ └── PoolManager.ts # Generic object pool ├── scenes/ │ ├── Boot.ts # Asset preloading, shared lib init │ ├── Menu.ts # Main menu │ ├── Gameplay.ts # Main gameplay (orchestrator only) │ └── GameOver.ts # Results screen ├── gameplay/ │ ├── player/ │ │ ├── Player.ts # Player entity with FSM │ │ ├── PlayerMovement.ts # Movement logic │ │ ├── PlayerCombat.ts # Combat logic │ │ └── PlayerState.ts # Player FSM states │ ├── enemies/ │ │ ├── BaseEnemy.ts # Enemy base class │ │ ├── EnemyState.ts # Enemy FSM states │ │ └── EnemyFactory.ts # Factory for spawning enemies │ ├── combat/ │ │ ├── DamageCalculator.ts # Damage formula │ │ ├── Hitbox.ts # Collision logic │ │ └── StatusEffect.ts # Buff/debuff system │ ├── economy/ │ │ ├── CurrencyManager.ts # Currency tracking │ │ └── ScoreManager.ts # Score display │ └── level/ │ ├── LevelManager.ts # Level loading, progression │ └── SpawnManager.ts # Enemy/object spawning ├── services/ │ ├── AudioService.ts # Wrapper for AudioManager │ ├── VFXService.ts # Wrapper for VFX helpers │ └── SaveService.ts # LocalStorage persistence ├── ui/ │ ├── HUD.ts # HUD layer │ └── GameOverUI.ts # Game over screen └── types/ ├── game.d.ts # Global type declarations └── phaser-extensions.d.ts # Phaser class extensions ``` ## Phase 1 — Project Scaffolding & Core Framework **Goal:** Set up TypeScript project, Phaser 3 configuration, and foundational architecture. ### Step 1.1: Project Setup ```bash npm init -y npm install phaser typescript vite @types/node npx tsc --init ``` ### Step 1.2: tsconfig.json ```json { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": true, "noUnusedParameters": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` ### Step 1.3: Vite Config ```typescript // vite.config.ts import { defineConfig } from 'vite'; export default defineConfig({ base: './', build: { outDir: 'dist', assetsDir: 'assets', sourcemap: true, }, server: { port: 3000, open: true, }, }); ``` ### Step 1.4: Game Entry Point ```typescript // src/main.ts import Phaser from 'phaser'; import { Boot } from './scenes/Boot'; import { Menu } from './scenes/Menu'; import { Gameplay } from './scenes/Gameplay'; import { GameOver } from './scenes/GameOver'; const config: Phaser.Types.Core.GameConfig = { type: Phaser.AUTO, width: 480, height: 800, physics: { default: 'arcade', arcade: { gravity: { y: 800 }, debug: false, }, }, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, }, scene: [Boot, Menu, Gameplay, GameOver], }; export const game = new Phaser.Game(config); ``` ### Step 1.5: Constants Files ```typescript // src/constants/SceneKeys.ts export const SceneKeys = { Boot: 'Boot', Menu: 'Menu', Gameplay: 'Gameplay', GameOver: 'GameOver', } as const; export type SceneKey = typeof SceneKeys[keyof typeof SceneKeys]; // src/constants/AssetKeys.ts export const AssetKeys = { // Sprites sprPlayer: 'spr_player_idle', sprEnemy: 'spr_enemy_basic', sprBullet: 'spr_bullet', sprGem: 'tx_gem', sprParticle: 'tx_particle', // UI txBtnPrimary: 'tx_btn_primary', txBtnSecondary: 'tx_btn_secondary', // VFX txParticle: 'tx_particle', } as const; // src/constants/GameConfig.ts export interface GameBalance { player: { startingHealth: number; speed: number; jumpForce: number; }; enemy: { baseHealth: number; baseSpeed: number; baseDamage: number; }; combat: { critChance: number; critMultiplier: number; defenseReduction: number; }; } export const DEFAULT_BALANCE: GameBalance = { player: { startingHealth: 100, speed: 200, jumpForce: 450, }, enemy: { baseHealth: 30, baseSpeed: 100, baseDamage: 10, }, combat: { critChance: 0.1, critMultiplier: 2.0, defenseReduction: 0.5, }, }; ``` ### Step 1.6: GameEvents (Global Event Emitter) ```typescript // src/core/GameEvents.ts import Phaser from 'phaser'; export const GameEvents = new Phaser.Events.EventEmitter(); export const GameEventTypes = { // Player events PlayerDied: 'player:died', PlayerDamaged: 'player:damaged', PlayerHealthChanged: 'player:health_changed', // Combat events EnemyKilled: 'enemy:killed', DamageDealt: 'combat:damage_dealt', // Game state events ScoreChanged: 'score:changed', LevelComplete: 'level:complete', GameOver: 'game:over', PauseRequested: 'pause:requested', // Economy events CurrencyChanged: 'currency:changed', ItemAcquired: 'item:acquired', // UI events ButtonClicked: 'ui:button_clicked', } as const; // Typed event payloads export interface PlayerDiedPayload { playerId: string; finalScore: number; } export interface EnemyKilledPayload { enemyId: string; position: { x: number; y: number }; scoreValue: number; } export interface DamagePayload { targetId: string; amount: number; isCritical: boolean; } ``` ### Step 1.7: State Machine ```typescript // src/core/StateMachine.ts export interface State { enter(): void; update(dt: number): void; exit(): void; } type TransitionMap = Record string)>; export abstract class StateMachine { protected states: Map = new Map(); protected currentState: T | null = null; protected transitions: TransitionMap = {}; protected stateName: string = ''; addState(name: string, state: T): this { this.states.set(name, state); return this; } setTransitions(map: TransitionMap): this { this.transitions = map; return this; } transitionTo(stateName: string): void { const next = this.states.get(stateName); if (!next) { console.warn(`State ${stateName} not found`); return; } if (this.currentState) { this.currentState.exit(); } this.currentState = next; this.stateName = stateName; this.currentState.enter(); } canTransition(event: string): boolean { return this.transitions[event] !== undefined; } getCurrentStateName(): string { return this.stateName; } update(dt: number): void { this.currentState?.update(dt); } protected getNextState(event: string): string | null { const next = this.transitions[event]; if (typeof next === 'function') { return next(); } return next ?? null; } } ``` ### Step 1.8: Pool Manager ```typescript // src/core/PoolManager.ts export class PoolManager { private pool: T[] = []; private active: Set = new Set(); private createFn: () => T; private resetFn: (obj: T) => void; constructor( private scene: Phaser.Scene, createFn: () => T, resetFn: (obj: T) => void, private initialSize = 10 ) { this.createFn = createFn; this.resetFn = resetFn; this.prewarm(initialSize); // Cleanup on scene destroy this.scene.events.once(Phaser.Scenes.Events.DESTROY, this.destroy, this); } acquire(): T { const obj = this.pool.pop() ?? this.createFn(); obj.setActive(true).setVisible(true); this.active.add(obj); return obj; } release(obj: T): void { obj.setActive(false).setVisible(false); this.resetFn(obj); this.active.delete(obj); this.pool.push(obj); } prewarm(count: number): void { for (let i = 0; i < count; i++) { const obj = this.createFn(); obj.setActive(false).setVisible(false); this.pool.push(obj); } } getActiveCount(): number { return this.active.size; } getPoolCount(): number { return this.pool.length; } releaseAll(): void { for (const obj of this.active) { obj.setActive(false).setVisible(false); this.resetFn(obj); this.pool.push(obj); } this.active.clear(); } private destroy(): void { for (const obj of this.pool) { obj.destroy(); } for (const obj of this.active) { obj.destroy(); } this.pool = []; this.active.clear(); } } ``` ## Phase 2 — Scenes & Boot Pipeline **Goal:** Build scene architecture following shared library patterns. ### Step 2.1: Boot Scene ```typescript // src/scenes/Boot.ts import { Scene } from 'phaser'; import { UI, VFX, THEME } from '@shared/lib/ui-helpers.js'; import { SceneKeys } from '../constants/SceneKeys'; export class Boot extends Scene { constructor() { super(SceneKeys.Boot); } preload(): void { this.createLoadingUI(); // Generate procedural textures this.generateTextures(); // Load external assets this.load.audio('sfx_click', 'assets/sfx/click.mp3'); this.load.audio('sfx_drop', 'assets/sfx/drop.mp3'); this.load.audio('sfx_select', 'assets/sfx/select.mp3'); this.load.audio('music_menu', 'assets/music/menu.mp3'); this.load.on('complete', () => { this.time.delayedCall(1500, () => { this.cameras.main.fadeOut(300); this.time.delayedCall(300, () => { this.scene.start(SceneKeys.Menu); }); }); }); } private createLoadingUI(): void { const bar = UI.createProgressBar(this, 240, 450, 200, 12, { showText: true }); this.load.on('progress', (p: number) => bar.setProgress(p)); } private generateTextures(): void { // Gem texture (Level 3 polished) this.generateGemTexture(); // Particle texture this.generateParticleTexture(); // Button textures this.generateButtonTextures(); } private generateGemTexture(): void { const g = this.make.graphics({ add: false }); const size = 32; // Layer 1: Base with gradient-like fill g.fillStyle(0xcc2244); g.fillTriangle(size/2, 2, 4, size*0.4, size-4, size*0.4); g.fillStyle(0xaa1133); g.fillTriangle(4, size*0.4, size-4, size*0.4, size/2, size-2); // Layer 2: Shadow g.fillStyle(0x660022, 0.4); g.fillTriangle(size*0.6, size*0.4, size-4, size*0.4, size/2, size-2); // Layer 3: Highlight g.fillStyle(0xffffff, 0.5); g.fillTriangle(size/2, 4, size*0.35, size*0.35, size*0.55, size*0.25); // Layer 4: Center glow g.fillStyle(0xff88aa, 0.6); g.fillCircle(size/2, size*0.38, 3); g.generateTexture('tx_gem', size, size); g.destroy(); } private generateParticleTexture(): void { const g = this.make.graphics({ add: false }); g.fillStyle(0xffffff); g.fillCircle(8, 8, 8); g.generateTexture('tx_particle', 16, 16); g.destroy(); } private generateButtonTextures(): void { // Primary button const g = this.make.graphics({ add: false }); g.fillStyle(THEME.primary); g.fillRoundedRect(0, 0, 160, 48, 8); // Highlight g.fillStyle(0xffffff, 0.15); g.fillRoundedRect(4, 4, 152, 20, 6); g.generateTexture('tx_btn_primary', 160, 48); g.destroy(); // Secondary button const g2 = this.make.graphics({ add: false }); g2.lineStyle(2, THEME.primary); g2.strokeRoundedRect(0, 0, 160, 48, 8); g2.generateTexture('tx_btn_secondary', 160, 48); g2.destroy(); } } ``` ### Step 2.2: Menu Scene ```typescript // src/scenes/Menu.ts import { Scene } from 'phaser'; import { UI, VFX, THEME } from '@shared/lib/ui-helpers.js'; import { AudioManager } from '@shared/lib/audio-manager.js'; import { SceneKeys } from '../constants/SceneKeys'; import { SaveService } from '../services/SaveService'; export class Menu extends Scene { private audio!: AudioManager; constructor() { super(SceneKeys.Menu); } create(): void { this.cameras.main.fadeIn(300); // Background UI.createGradientBg(this, 0x0a0e27, 0x1a1040); VFX.ambientParticles(this, { count: 30 }); // Title const title = this.add.text(240, 180, 'MY GAME', { fontFamily: THEME.fontFamily, fontSize: '48px', fontWeight: 'bold', color: '#00d4ff', }).setOrigin(0.5); title.setShadow(0, 4, '#000000', 8); // Animate title this.tweens.add({ targets: title, scaleX: 1.02, scaleY: 1.02, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); // Best score const bestScore = SaveService.load('best_score', 0); this.add.text(240, 260, `Best: ${bestScore}`, { fontFamily: THEME.fontFamily, fontSize: '16px', color: '#8899aa', }).setOrigin(0.5); // Play button const playBtn = UI.createButton(this, 240, 380, 'PLAY', { style: 'primary', onClick: () => this.startGame(), }); // Options button const optionsBtn = UI.createButton(this, 240, 460, 'OPTIONS', { style: 'outline', }); // Sound toggle (top-right) const soundBtn = this.add.text(460, 20, '🔊', { fontFamily: THEME.fontFamily, fontSize: '24px', }).setOrigin(0.5).setInteractive({ useHandCursor: true }); // Audio this.audio = new AudioManager(this); this.audio.playMusic('music_menu'); // Transitions this.cameras.main.fadeOut(300); } private startGame(): void { this.audio.playSFX('click'); this.cameras.main.fadeOut(300); this.time.delayedCall(300, () => { this.scene.start(SceneKeys.Gameplay); }); } } ``` ### Step 2.3: Gameplay Scene (Orchestrator) ```typescript // src/scenes/Gameplay.ts import { Scene } from 'phaser'; import { UI, VFX, THEME } from '@shared/lib/ui-helpers.js'; import { AudioManager } from '@shared/lib/audio-manager.js'; import { SceneKeys } from '../constants/SceneKeys'; import { GameEvents, GameEventTypes } from '../core/GameEvents'; import { Player } from '../gameplay/player/Player'; import { EnemyFactory } from '../gameplay/enemies/EnemyFactory'; import { PoolManager } from '../core/PoolManager'; import { LevelManager } from '../gameplay/level/LevelManager'; import { HUD } from '../ui/HUD'; import { ScoreManager } from '../gameplay/economy/ScoreManager'; export class Gameplay extends Scene { // Services private player!: Player; private enemyPool!: PoolManager; private enemyFactory!: EnemyFactory; private levelManager!: LevelManager; private hud!: HUD; private audio!: AudioManager; // State private score = 0; constructor() { super(SceneKeys.Gameplay); } create(): void { this.cameras.main.fadeIn(300); // Initialize services this.audio = new AudioManager(this); this.audio.playMusic('action-loop'); // Create enemy pool this.enemyPool = new PoolManager( this, () => this.createEnemy(), (e) => e.reset(), 20 ); this.enemyPool.prewarm(20); this.enemyFactory = new EnemyFactory(this, this.enemyPool); // Create player this.player = new Player(this, 240, 400); // Create level manager this.levelManager = new LevelManager(this, this.enemyFactory); this.levelManager.start(); // Create HUD this.hud = new HUD(this, this.player.getHealth()); // Subscribe to events GameEvents.on(GameEventTypes.EnemyKilled, this.onEnemyKilled, this); GameEvents.on(GameEventTypes.PlayerDied, this.onPlayerDied, this); GameEvents.on(GameEventTypes.DamageDealt, this.onDamageDealt, this); // Input this.input.keyboard?.on('keydown-ESC', () => { GameEvents.emit(GameEventTypes.PauseRequested); }); } update(_time: number, _delta: number): void { // Orchestrator only — coordinate systems this.player.update(_delta); this.levelManager.update(_delta); } private onEnemyKilled(payload: { position: { x: number; y: number }; scoreValue: number }): void { this.score += payload.scoreValue; this.hud.updateScore(this.score); // VFX VFX.particleBurst(this, payload.position.x, payload.position.y, 0xff4444, 8); VFX.scorePop(this, payload.position.x, payload.position.y - 20, payload.scoreValue, '#FFD700'); VFX.screenShake(this, 2, 50); // Audio this.audio.playSFX('select'); } private onPlayerDied(): void { this.cameras.main.fadeOut(500); this.time.delayedCall(500, () => { GameEvents.off(GameEventTypes.EnemyKilled, this.onEnemyKilled, this); this.scene.start(SceneKeys.GameOver, { score: this.score }); }); } private onDamageDealt(_payload: unknown): void { // Could trigger damage VFX here } private createEnemy(): unknown { // Factory creates enemies using pool return {}; } shutdown(): void { GameEvents.off(GameEventTypes.EnemyKilled, this.onEnemyKilled, this); GameEvents.off(GameEventTypes.PlayerDied, this.onPlayerDied, this); GameEvents.off(GameEventTypes.DamageDealt, this.onDamageDealt, this); } } ``` ### Step 2.4: GameOver Scene ```typescript // src/scenes/GameOver.ts import { Scene } from 'phaser'; import { UI, VFX, THEME } from '@shared/lib/ui-helpers.js'; import { AudioManager } from '@shared/lib/audio-manager.js'; import { SceneKeys } from '../constants/SceneKeys'; import { SaveService } from '../services/SaveService'; interface GameOverData { score: number; } export class GameOver extends Scene { private audio!: AudioManager; constructor() { super(SceneKeys.GameOver); } init(data: GameOverData): void { this.data.set('score', data.score); } create(): void { // Overlay UI.createOverlay(this, { alpha: 0.7 }); this.cameras.main.fadeIn(300); // Panel const panel = UI.createPanel(this, 240, 400, 320, 360); panel.setDepth(100); // Title this.add.text(240, 240, 'GAME OVER', { fontFamily: THEME.fontFamily, fontSize: '36px', fontWeight: 'bold', color: '#ffffff', }).setOrigin(0.5).setDepth(101); // Score display with animation const score = this.data.get('score', 0) as number; const scoreText = this.add.text(240, 320, '0', { fontFamily: THEME.fontFamily, fontSize: '48px', fontWeight: 'bold', color: '#FFD700', }).setOrigin(0.5).setDepth(101); // Animate score count-up this.tweens.addCounter({ from: 0, to: score, duration: 1500, onUpdate: (tween) => { scoreText.setText(Math.floor(tween.getValue()).toString()); }, }); // Best score const bestScore = SaveService.load('best_score', 0); const isNewBest = score > bestScore; if (isNewBest) { SaveService.save('best_score', score); } this.add.text(240, 370, isNewBest ? 'NEW BEST!' : `Best: ${bestScore}`, { fontFamily: THEME.fontFamily, fontSize: '18px', color: isNewBest ? '#51cf66' : '#8899aa', }).setOrigin(0.5).setDepth(101); // Star rating const stars = this.calculateStars(score); UI.showStarRating(this, 240, 420, stars, 3); // Buttons const retryBtn = UI.createButton(this, 240, 510, 'RETRY', { style: 'primary', onClick: () => this.retry(), }); retryBtn.setDepth(101); const menuBtn = UI.createButton(this, 240, 570, 'MENU', { style: 'outline', onClick: () => this.goToMenu(), }); menuBtn.setDepth(101); // Audio this.audio = new AudioManager(this); // Confetti for 3 stars if (stars === 3) { this.time.delayedCall(2000, () => { VFX.confetti(this, 240, 400, { count: 50 }); this.audio.playSFX('select'); }); } } private calculateStars(score: number): number { if (score >= 1000) return 3; if (score >= 500) return 2; return 1; } private retry(): void { this.audio.playSFX('click'); this.cameras.main.fadeOut(300); this.time.delayedCall(300, () => { this.scene.start(SceneKeys.Gameplay); }); } private goToMenu(): void { this.audio.playSFX('click'); this.cameras.main.fadeOut(300); this.time.delayedCall(300, () => { this.scene.start(SceneKeys.Menu); }); } } ``` ## Phase 3 — Gameplay Systems **Goal:** Implement gameplay systems from Game Designer specs. ### Step 3.1: Player Entity with FSM ```typescript // src/gameplay/player/Player.ts import Phaser from 'phaser'; import { StateMachine } from '../../core/StateMachine'; import { PlayerState, PlayerIdleState, PlayerWalkState, PlayerJumpState, PlayerAttackState } from './PlayerState'; import { GameEvents, GameEventTypes } from '../../core/GameEvents'; export class Player extends Phaser.Physics.Arcade.Sprite { private stateMachine: StateMachine; private health: number; private maxHealth: number; private speed: number; private jumpForce: number; private isGrounded = false; private facingRight = true; constructor(scene: Phaser.Scene, x: number, y: number) { super(scene, x, y, 'spr_player'); scene.add.existing(this); scene.physics.add.existing(this); this.setCollideWorldBounds(true); this.body?.setSize(32, 48); this.body?.setOffset(8, 0); this.health = 100; this.maxHealth = 100; this.speed = 200; this.jumpForce = 450; // State machine this.stateMachine = new StateMachine() .addState('idle', new PlayerIdleState(this)) .addState('walk', new PlayerWalkState(this)) .addState('jump', new PlayerJumpState(this)) .addState('attack', new PlayerAttackState(this)) .setTransitions({ idle: () => this.getTransitionState(), walk: () => this.getTransitionState(), jump: () => this.isGrounded ? 'idle' : 'jump', attack: () => 'idle', }) .transitionTo('idle'); // Input scene.input.keyboard?.on('keydown-SPACE', () => this.jump()); scene.input.keyboard?.on('keydown-A', () => this.attack()); } update(_delta: number): void { // Horizontal movement const left = this.scene.input.keyboard?.addKey('LEFT'); const right = this.scene.input.keyboard?.addKey('RIGHT'); if (left?.isDown) { this.setVelocityX(-this.speed); this.facingRight = false; this.setFlipX(true); } else if (right?.isDown) { this.setVelocityX(this.speed); this.facingRight = true; this.setFlipX(false); } else { this.setVelocityX(0); } // Check grounded this.isGrounded = this.body?.blocked.down ?? false; // Update state machine this.stateMachine.update(_delta); } private getTransitionState(): string { if (!this.isGrounded) return 'jump'; const left = this.scene.input.keyboard?.addKey('LEFT'); const right = this.scene.input.keyboard?.addKey('RIGHT'); if (left?.isDown || right?.isDown) return 'walk'; return 'idle'; } jump(): void { if (this.isGrounded) { this.body?.setVelocityY(-this.jumpForce); } } attack(): void { this.stateMachine.transitionTo('attack'); // Trigger attack logic } takeDamage(amount: number): void { this.health -= amount; GameEvents.emit(GameEventTypes.PlayerHealthChanged, { current: this.health, max: this.maxHealth, }); if (this.health <= 0) { GameEvents.emit(GameEventTypes.PlayerDied); } } getHealth(): { current: number; max: number } { return { current: this.health, max: this.maxHealth }; } isFacingRight(): boolean { return this.facingRight; } } ``` ### Step 3.2: Player States ```typescript // src/gameplay/player/PlayerState.ts import { State } from '../../core/StateMachine'; export class PlayerState implements State { constructor(protected player: Player) {} enter(): void {} update(_dt: number): void {} exit(): void {} } export class PlayerIdleState extends PlayerState { enter(): void { this.player.play('player_idle'); } } export class PlayerWalkState extends PlayerState { enter(): void { this.player.play('player_walk'); } } export class PlayerJumpState extends PlayerState { enter(): void { this.player.play('player_jump'); } } export class PlayerAttackState extends PlayerState { enter(): void { this.player.play('player_attack'); // Attack logic after animation this.player.scene.time.delayedCall(200, () => { // Deal damage to enemies in range }); // Return to idle after attack this.player.scene.time.delayedCall(500, () => { this.player.stateMachine.transitionTo('idle'); }); } update(_dt: number): void { this.player.setVelocityX(0); // No movement during attack } } ``` ### Step 3.3: Damage Calculator ```typescript // src/gameplay/combat/DamageCalculator.ts export interface DamageInput { attackerATK: number; defenderDEF: number; skillMultiplier: number; isCritical: boolean; } // Formula: (ATK * skill_multiplier - DEF * 0.5) * (1 + crit_damage * crit_chance) export class DamageCalculator { static calculate(input: DamageInput): number { const baseDamage = input.attackerATK * input.skillMultiplier; const defenseReduction = input.defenderDEF * 0.5; const netDamage = Math.max(1, baseDamage - defenseReduction); const critMultiplier = input.isCritical ? 2 : 1; return Math.floor(netDamage * critMultiplier); } static isCriticalHit(critChance: number): boolean { return Math.random() < critChance; } static calculateDamageWithCrit( attackerATK: number, defenderDEF: number, skillMultiplier: number, critChance: number, critMultiplier: number ): number { const isCrit = this.isCriticalHit(critChance); return this.calculate({ attackerATK, defenderDEF, skillMultiplier, isCritical: isCrit, }); } } ``` ### Step 3.4: Enemy Factory ```typescript // src/gameplay/enemies/EnemyFactory.ts import Phaser from 'phaser'; import { PoolManager } from '../../core/PoolManager'; import { EnemyState } from './EnemyState'; import { GameEvents, GameEventTypes } from '../../core/GameEvents'; export interface EnemyType { key: string; health: number; speed: number; damage: number; scoreValue: number; texture: string; } const ENEMY_TYPES: Record = { basic: { key: 'basic', health: 30, speed: 100, damage: 10, scoreValue: 10, texture: 'spr_enemy', }, fast: { key: 'fast', health: 20, speed: 180, damage: 5, scoreValue: 15, texture: 'spr_enemy_fast', }, tank: { key: 'tank', health: 80, speed: 50, damage: 20, scoreValue: 25, texture: 'spr_enemy_tank', }, }; export class EnemyFactory { constructor( private scene: Phaser.Scene, private pool: PoolManager ) {} spawn(type: string, x: number, y: number): Phaser.GameObjects.Sprite { const enemy = this.pool.acquire(); const config = ENEMY_TYPES[type] ?? ENEMY_TYPES.basic; enemy.setTexture(config.texture); enemy.setPosition(x, y); enemy.setActive(true); enemy.setVisible(true); // Enemy state (enemy as any).type = config; (enemy as any).state = 'active'; (enemy as any).health = config.health; return enemy; } kill(enemy: Phaser.GameObjects.Sprite): void { const type = (enemy as any).type as EnemyType; GameEvents.emit(GameEventTypes.EnemyKilled, { enemyId: crypto.randomUUID(), position: { x: enemy.x, y: enemy.y }, scoreValue: type.scoreValue, }); this.pool.release(enemy); } } ``` ### Step 3.5: Level Manager ```typescript // src/gameplay/level/LevelManager.ts import Phaser from 'phaser'; import { EnemyFactory, ENEMY_TYPES } from '../enemies/EnemyFactory'; export interface Wave { enemies: Array<{ type: string; count: number; delay: number }>; spawnDelay: number; } export class LevelManager { private currentWave = 0; private waves: Wave[] = []; private enemiesRemaining = 0; private isSpawning = false; constructor( private scene: Phaser.Scene, private enemyFactory: EnemyFactory ) { this.loadLevelData(); } start(): void { this.currentWave = 0; this.startNextWave(); } update(_delta: number): void { // Check wave completion if (this.enemiesRemaining === 0 && !this.isSpawning) { this.currentWave++; if (this.currentWave < this.waves.length) { this.startNextWave(); } else { // Level complete } } } private startNextWave(): void { const wave = this.waves[this.currentWave]; if (!wave) return; this.isSpawning = true; this.enemiesRemaining = wave.enemies.reduce((sum, e) => sum + e.count, 0); let delay = 0; for (const enemy of wave.enemies) { for (let i = 0; i < enemy.count; i++) { this.scene.time.delayedCall(delay, () => { this.spawnEnemy(enemy.type); }); delay += enemy.delay; } } this.isSpawning = false; } private spawnEnemy(type: string): void { const x = Phaser.Math.Between(50, 430); const enemy = this.enemyFactory.spawn(type, x, -50); } private loadLevelData(): void { this.waves = [ { enemies: [{ type: 'basic', count: 5, delay: 1000 }], spawnDelay: 3000, }, { enemies: [ { type: 'basic', count: 5, delay: 800 }, { type: 'fast', count: 3, delay: 500 }, ], spawnDelay: 2000, }, { enemies: [ { type: 'basic', count: 5, delay: 600 }, { type: 'fast', count: 5, delay: 400 }, { type: 'tank', count: 2, delay: 2000 }, ], spawnDelay: 1500, }, ]; } enemyKilled(): void { this.enemiesRemaining = Math.max(0, this.enemiesRemaining - 1); } } ``` ### Step 3.6: HUD ```typescript // src/ui/HUD.ts import { Container, Text, Graphics } from 'phaser'; import { UI, THEME } from '@shared/lib/ui-helpers.js'; import { GameEvents, GameEventTypes } from '../core/GameEvents'; export class HUD extends Container { private healthBar!: Graphics; private healthText!: Text; private scoreText!: Text; constructor(scene: Phaser.Scene, health: { current: number; max: number }) { super(scene, 0, 0); scene.add.existing(this); this.setDepth(1000); this.createHealthBar(health); this.createScoreDisplay(); GameEvents.on(GameEventTypes.PlayerHealthChanged, this.onHealthChanged, this); GameEvents.on(GameEventTypes.ScoreChanged, this.onScoreChanged, this); } private createHealthBar(health: { current: number; max: number }): void { // Health bar background this.healthBar = this.scene.add.graphics(); this.healthBar.fillStyle(THEME.bgCard); this.healthBar.fillRoundedRect(10, 10, 150, 20, 8); // Health bar fill this.updateHealthBar(health.current, health.max); this.add(this.healthBar); // Health text this.healthText = this.scene.add.text(85, 20, `${health.current}/${health.max}`, { fontFamily: THEME.fontFamily, fontSize: '12px', color: '#ffffff', }).setOrigin(0.5); this.add(this.healthText); } private createScoreDisplay(): void { this.scoreText = this.scene.add.text(470, 20, '0', { fontFamily: THEME.fontFamily, fontSize: '20px', fontWeight: 'bold', color: '#FFD700', }).setOrigin(1, 0.5); this.add(this.scoreText); } updateScore(score: number): void { this.scene.tweens.addCounter({ from: parseInt(this.scoreText.text) || 0, to: score, duration: 300, onUpdate: (tween) => { this.scoreText.setText(Math.floor(tween.getValue()).toString()); }, }); } private updateHealthBar(current: number, max: number): void { const percentage = Math.max(0, current / max); const width = 146 * percentage; this.healthBar.clear(); this.healthBar.fillStyle(THEME.bgCard); this.healthBar.fillRoundedRect(12, 12, 146, 16, 6); this.healthBar.fillStyle(THEME.danger); this.healthBar.fillRoundedRect(12, 12, width, 16, 6); } private onHealthChanged(data: { current: number; max: number }): void { this.updateHealthBar(data.current, data.max); this.healthText.setText(`${data.current}/${data.max}`); } private onScoreChanged(score: number): void { this.updateScore(score); } shutdown(): void { GameEvents.off(GameEventTypes.PlayerHealthChanged, this.onHealthChanged, this); GameEvents.off(GameEventTypes.ScoreChanged, this.onScoreChanged, this); } } ``` ## Phase 4 — Services ### Step 4.1: Save Service ```typescript // src/services/SaveService.ts const PREFIX = 'game_save_'; export class SaveService { static save(key: string, data: unknown): void { try { localStorage.setItem(PREFIX + key, JSON.stringify(data)); } catch (e) { console.error('Failed to save:', e); } } static load(key: string, defaultValue: T): T { try { const raw = localStorage.getItem(PREFIX + key); return raw ? JSON.parse(raw) : defaultValue; } catch { return defaultValue; } } static remove(key: string): void { localStorage.removeItem(PREFIX + key); } static saveScore(score: number): void { const best = this.load('best_score', 0); if (score > best) { this.save('best_score', score); } } static clearAll(): void { const keys = Object.keys(localStorage); for (const key of keys) { if (key.startsWith(PREFIX)) { localStorage.removeItem(key); } } } } ``` ### Step 4.2: VFX Service ```typescript // src/services/VFXService.ts import { Scene } from 'phaser'; import { VFX, THEME } from '@shared/lib/ui-helpers.js'; export class VFXService { constructor(private scene: Scene) {} onPlayerHit(intensity = 1): void { VFX.screenShake(this.scene, 5 * intensity, 100); VFX.flashScreen(this.scene, 0xff0000, 150, 0.3); VFX.vignetteFlash(this.scene, 200, 0.5); } onEnemyDestroyed(x: number, y: number): void { VFX.particleBurst(this.scene, x, y, 0xff4444, 8); VFX.scorePop(this.scene, x, y - 20, 100, '#FFD700'); VFX.screenShake(this.scene, 2, 50); } onCollectItem(x: number, y: number, type: string): void { const color = type === 'gem' ? 0xffd700 : 0x00d4ff; VFX.sparkleTrail(this.scene, x, y, 5, color); } onCombo(count: number): void { VFX.comboFlash(this.scene, count); } onLevelComplete(): void { VFX.confetti(this.scene, 240, 400, { count: 40 }); VFX.flashScreen(this.scene, 0xffffff, 200, 0.5); } } ``` ## Phase 5 — Testing Integration ### Unit Test Example ```typescript // tests/unit/DamageCalculator.test.ts import { DamageCalculator } from '../../src/gameplay/combat/DamageCalculator'; describe('DamageCalculator', () => { it('should apply crit multiplier correctly', () => { // (50 * 1.5 - 20 * 0.5) * 2 = 60 const damage = DamageCalculator.calculate({ attackerATK: 50, defenderDEF: 20, skillMultiplier: 1.5, isCritical: true, }); expect(damage).toBe(60); }); it('should not deal less than 1 damage', () => { const damage = DamageCalculator.calculate({ attackerATK: 5, defenderDEF: 100, skillMultiplier: 1.0, isCritical: false, }); expect(damage).toBe(1); }); }); ``` ## Common Mistakes | # | Mistake | Why It Fails | Solution | |---|---------|---------------|----------| | 1 | Scene with 500+ lines | God Object anti-pattern | Extract to Player.ts, EnemyFactory.ts | | 2 | `any` types | Defeats type safety | Strict TypeScript | | 3 | Creating objects in `update()` | GC spikes, FPS drops | Pool in create() | | 4 | Scene-to-scene direct access | Tight coupling | Use GameEvents | | 5 | Magic numbers | Unmaintainable | Constants file | | 6 | Building UI/VFX from scratch | Inconsistent | Use shared libs | | 7 | No mobile detection | Desktop works, mobile crashes | Detect `device.os.desktop` | | 8 | Hardcoded dimensions | Not responsive | Use `this.scale.gameSize` | | 9 | State machine with if/else | Untestable, fragile | Declarative transition map | | 10 | WebGL-only on mobile | Crashes on old devices | Use `Phaser.AUTO` | ## Execution Checklist - [ ] TypeScript project scaffold with strict tsconfig - [ ] Phaser 3 game config with AUTO renderer + FIT scale - [ ] Constants file with typed SceneKeys and AssetKeys - [ ] Global GameEvents emitter with typed event payloads - [ ] StateMachine base class with declarative transitions - [ ] PoolManager with pre-warm in scene create() - [ ] Boot scene with procedural texture generation - [ ] Menu scene following full menu screen checklist - [ ] GameOver scene with panel, animated score, star rating, confetti - [ ] Gameplay scene as orchestrator - [ ] Player entity with FSM states - [ ] DamageCalculator implementing exact formula - [ ] EnemyFactory with typed EnemyType definitions - [ ] LevelManager with wave-based spawning - [ ] HUD with reactive bindings to GameEvents - [ ] VFXService wrapping shared vfx-helpers - [ ] AudioService with SFX paired to every player action - [ ] SaveService for localStorage persistence - [ ] Mobile detection with reduced particle/VFX budgets - [ ] All business logic in testable TypeScript classes - [ ] Unit tests for DamageCalculator, StateMachine