
Late 1970s Atari — Cover-based dueling, bullet collision, AI reaction timing, destructible obstacles. Teaches hitbox design and reactive enemy AI.
Two Cowboys
CompletePrompt used
Show prompt — Step 1
Create a two-player cowboy duel game in Phaser 3.60 with: - Two player sprites: Blue (P1) on the left, Red (P2) on the right - P1 controls: WASD to move, SPACE to fire - P2 controls: Arrow keys to move, ENTER to fire - Bullets are small rectangles that travel horizontally - Fire rate throttled at 350ms between shots - First to 5 hits wins - On hit, both players reset to spawn instantly - 3 URL-parameter modes: * No param = PvP (two players, one keyboard) * ?mode=ai = Vs AI (P2 controlled by simple tracking AI) * ?mode=bot = Bot Watch (both players AI-controlled) - AI in bot mode: track opponent Y position and fire when aligned - R key restarts the game - Score text centered at top, win message below - Use generateTexture() for all sprites (cowboy = 28x50, bullet = 8x4) - Canvas: 680x480 with Phaser.Scale.FIT and CENTER_BOTH - Dark background: #0a0b14
Why learn this?
Gun Fight introduces multi-entity shooting mechanics. Unlike Pong where the ball moves automatically, Gun Fight requires the player to aim and fire. This adds an input-action-response loop: press a key, see a bullet travel, watch it hit or miss. Every shooter since relies on this loop. Understanding bullet hitboxes, travel time, and fire rate teaches you the foundation of any action game.
What you built
Two cowboys on a flat duel field. Three modes: 2-Player (WASD vs Arrows), Vs AI (?ai — you vs computer), and Bot vs Bot (?bot — watch AI duel itself). First to 5 wins. Hit your opponent to score — players reset to spawn instantly.
Variants (A/B testing)
v1 — DeepSeek V4 Flash (original)
Original build. 680x480 canvas, rectangle cowboys, basic tracking AI. Bullet speed 500px/s. The initial release that established the core duel loop.
▶ Play v1v2 — Mimo v2.5 (A/B)
Mimo v2.5 on OpenRouter (DigitalOcean). 1020x720 canvas, full 3-mode PvP/AI/Bot, 750px/s bullets, JustDown fire. Cleaner state management and proper body.enable patterns. Generated in a single pass — no rewrites needed.
▶ Play v2 (Mimo)Key concepts
- Bullet pooling —
this.physics.add.group()for bullets. Enable on fire, disable on hit or off-screen. Prevents unlimited sprite creation. - Rectangle sprites with tint —
this.add.rectangle()withsetTint()for cowboys. Simple, no asset loading needed. - Fire rate throttling — A timestamp check
time > lastFired + fireRateprevents bullet spamming. - Overlap collision with callbackScope —
this.physics.add.overlap(group, sprite, method, null, this)— the 5th argthis(=callbackScope) ensures the callback runs with the scene asthis. Without it, class method callbacks lose their scope.
Design decisions & tradeoffs
- Rectangle vs sprite art — Rectangles are fast to render and need zero assets. Tradeoff: no animation frames for walking, shooting, or dying. Acceptable for step 1.
- Bullet speed — Fast enough to feel responsive (500px/s), slow enough to dodge. Adjustable per difficulty tier.
- Fire on release vs press — SPACE fires on press. Instant response. Alternative: charge-and-release for variable bullet speed (future step).
Performance notes
- 4 sprites (2 cowboys + 2 active bullets max) = trivial draw call.
- Arcade physics overhead for 4 dynamic bodies is near-zero.
- Bot mode adds a few property reads per frame.
⚠️ Common pitfalls
- Bullet through opponent — If bullet speed exceeds 600px/s with arcade physics, it can pass through the target in a single frame. Fix: cap bullet speed or use swept collision.
- Double fire —
key.isDownfires every frame. UsePhaser.Input.Keyboard.JustDown(key)for one-shot input. - Bullet recycling — Don't destroy bullets on hit. Use
bullet.setActive(false).setVisible(false)and recycle. Otherwise the group grows unbounded.
💡 Tip: Use JustDown() for fire — it ensures exactly one bullet per press, even if the player holds the key.
// gun-fight.js — class-based scene, loaded via <script src>
class GunFightScene extends Phaser.Scene {
preload() {
var g = this.make.graphics({add: false});
g.fillStyle(0xffffff); g.fillRect(0,0,8,4);
g.generateTexture("bul", 8, 4);
g.fillStyle(0xffffff); g.fillRect(0,0,28,50);
g.generateTexture("cow", 28, 50);
g.destroy();
}
create() {
this.p1 = this.add.sprite(100,420,"cow").setTint(0x3388ff);
this.physics.add.existing(this.p1);
this.p2 = this.add.sprite(580,420,"cow").setTint(0xff4444);
this.physics.add.existing(this.p2);
this.b1 = this.physics.add.group({maxSize:3});
this.b2 = this.physics.add.group({maxSize:3});
// callbackScope: this — CRITICAL for class-based scenes
this.physics.add.overlap(this.b1, this.p2,
this.onHitP2, null, this);
this.physics.add.overlap(this.b2, this.p1,
this.onHitP1, null, this);
}
onHitP2(bullet, player) {
if (!bullet.active || this.gameOver) return;
bullet.setActive(false).setVisible(false);
this.s1++; this.syncScore();
}
onHitP1(bullet, player) {
if (!bullet.active || this.gameOver) return;
bullet.setActive(false).setVisible(false);
this.s2++; this.syncScore();
}
syncScore() {
if (this.s1 >= 5 || this.s2 >= 5) {
this.gameOver = true;
} else {
// Instant reset — no timers, no clear()
this.resetPositions();
}
}
}
new Phaser.Game({
type: Phaser.AUTO, width: 680, height: 480,
scale: { mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH },
physics: { default: "arcade" },
scene: GunFightScene
});// Fire rate throttling — prevents bullet spamming
// Each player tracks their last fire timestamp
// In create():
this.p1LastFired = 0;
this.p2LastFired = 0;
this.fireRate = 350; // ms between shots
// In update():
function tryFire(player, bullets, lastFiredKey, dir) {
if (time > this[lastFiredKey] + this.fireRate) {
let b = bullets.get(10, 10, 'bul');
if (b) {
b.setActive(true).setVisible(true);
b.body.enable = true;
b.setPosition(player.x + dir * 14, player.y);
b.body.velocity.x = dir * 500;
this[lastFiredKey] = time;
}
}
}
// Usage:
// tryFire(this.p1, this.b1, 'p1LastFired', 1); // P1 fires right
// tryFire(this.p2, this.b2, 'p2LastFired', -1); // P2 fires left// Parse URL parameters to determine game mode
// ?mode=ai → Vs AI (P2 controlled by AI)
// ?mode=bot → Bot Watch (both AI-controlled)
// No param → PvP (two players, one keyboard)
function getMode() {
let params = new URLSearchParams(window.location.search);
let mode = params.get('mode');
if (mode === 'ai') return 'ai';
if (mode === 'bot') return 'bot';
return 'pvp';
}
// In create():
this.mode = getMode();
// Usage in update():
if (this.mode === 'ai') {
this.updateAI(this.p2, this.b2, -1); // AI controls P2
} else if (this.mode === 'bot') {
this.updateAI(this.p1, this.b1, 1); // AI controls P1
this.updateAI(this.p2, this.b2, -1); // AI controls P2
}AI Opponent
In ProgressPrompt used
Show prompt — Step 2
Add cover obstacles and an AI opponent to the gun fight game in Phaser 3.60: ADDITIONS to the existing Step 1 game: - 3 crates (60x60, brown tint) as cover, placed at x=200, 510, 880, grounded at y=645 - Crates block bullets and player movement (physics collider) - AI state machine with 3 states: HIDE (behind cover), PEEK (emerge and fire), RETREAT (return to cover) - AI routes ABOVE crate height (y=540) when moving horizontally, drops behind cover at destination - 3 peek positions — never repeats the same spot twice - Burst fire: 1-3 bullets per peek with 200-400ms interval - Timing variance: hide 600-1400ms, peek 800-1800ms - P2 AI hideX=920, peekXs=[350, 690, 820] - Bot mode (?mode=bot): both P1 and P2 use mirrored state machines - P1 bot hideX=100, peekXs=[250, 380, 600] - Canvas: 1020x720 with Phaser.Scale.FIT and CENTER_BOTH - Preserve existing: PvP mode, controls, score, win condition - Keep generateTexture() for all sprites
Variants (A/B testing)
v1 — Original layout (crates at x=330, 510, 690, y=600)
Evenly spaced crates at ground level. Three symmetric lanes. AI peeked at [420, 600, 780]. Initial release with basic hide/peek/retreat state machine.
▶ Play v1v2 — Asymmetric spread (crates at x=140, 430, 890, y varied)
Crates pushed to edges for wider engagement lanes. Multiple Y-height iterations (560-610, then 520-630-580, then 150-645-400). Each iteration changed AI responsiveness and cover effectiveness.
v3 — Grounded symmetric (crates at x=200, 510, 880, y=645) [CURRENT]
All crates flush on the floor line. Left pulled away from corner, right not cramped against wall. Best balance of cover spacing and engagement distance. Updated AI peek positions to [350, 690, 820] for P2 and [250, 380, 600] for P1 bot.
▶ Play v3 (current)v4 — Mimo v2.5 (A/B)
Re-generated from scratch by Mimo v2.5 on OpenRouter (DigitalOcean). Same layout (x=200/510/880, y=180/645/400) but entirely independent code — different nav structure, AI implementation, and state machine. Clean single-pass generation at 19K chars for $0.002.
▶ Play v4 (Mimo)Why learn this?
Reactive enemy AI is what separates a static shooting gallery from a real opponent. The original Gun Fight was one of the first arcade games to use a microprocessor specifically to drive enemy behavior. This step introduces AI state machines — the same pattern used by every modern shooter from Halo to Call of Duty for enemy decision-making.
What you built
Three cover obstacles (crates) at x=200, 510, 880 that block bullets and player movement. The AI uses a parameterized state machine (runStateMachine()) with three states: HIDE behind cover, PEEK from one of 3 positions (never repeats), and RETREAT back to safety. Crucially, the AI always routes above crate height (y=540) when moving horizontally — it walks over crates to reach its peek and hide positions, then drops behind cover at the destination. This means the AI respects crate collision physics but navigates them naturally by climbing over. In ?bot mode, both players run mirrored state machines.
Key concepts
- Parameterized state machine —
runStateMachine(sprite, group, state, timer, ...)is a generic function reused for both P1 and P2. Each call receives its own hide position, peek positions, and fire direction. This eliminates code duplication between AI vs bot mode. - Multiple peek positions — P2 (red) peeks from [350, 690, 820] — never the same spot twice in a row. P1 (blue, bot mode) peeks from [250, 380, 600]. The player can't pre-aim one position.
- Burst fire pattern — During each peek, the AI fires 1-3 bullets with 200-400ms between shots. This replaces the probabilistic fire (12% per tick) with deliberate bursts, making the AI feel more aggressive and intentional.
- Vertical pop-up — During PEEK, the AI moves UP to y=540 (above crate tops at y=570). During HIDE/RETREAT, it stays at y=600-660 (behind crates). This creates a realistic cover pop-up animation that makes the AI visible while firing and hidden while waiting.
Design decisions & tradeoffs
- Parameterized vs separate state machines — A single
runStateMachine()function that takes a sprite + group + positions as args reduces code by 60% over two separate state machines. Tradeoff: the return-object pattern for state mutation is slightly awkward — each tick returns a new state object that must be destructured back into the caller's properties. - AI routes above crates — The AI always moves horizontally at y=540 (above crate tops at y=570). This avoids crate collision during transitions while keeping the physics collider active. The AI "climbs over" crates to reach peek positions — natural 2D side-scroller navigation.
- Timing variance — Hide duration 600-1400ms, peek duration 800-1800ms, shot interval 200-400ms. The wide variance prevents pattern memorization. The AI doesn't follow a fixed rhythm.
- Crate positions — x=200 (left, 80px from P1 spawn), x=510 (center), x=880 (right, 20px from P2 spawn). The 310px gap between left→center and 370px gap between center→right creates asymmetric engagement lanes.
Performance notes
- 3 static bodies + 2 dynamic bodies + bullet group = ~8 physics bodies total.
- Static group collision is cheaper than dynamic-vs-dynamic for cover.
- AI tick runs at 10Hz (100ms), not every frame — negligible CPU overhead.
⚠️ Common pitfalls
- State machine parameter explosion —
runStateMachine()takes 12 parameters because every property (state, timer, peek positions, bullets, etc.) differs between P1 and P2. Adding a new state variant means adding 2+ more params. Cleaner alternative: merge into a singleaiConfigobject on the scene, or use a worker-pattern function that owns its own state object. - Return-object destructuring — The state machine returns an object with 7 fields. Calling code must destructure every field back into instance properties. Miss one and the AI uses stale values.
- Random Y in every call —
moveTowardXY(sprite, hideX, 600 + Math.random() * 60)calls Math.random() every 100ms tick, producing jittery movement. The AI vibrates instead of moving to a fixed Y. Fix: pick the Y target once on state entry and reuse it. - Bot mode identically-skilled bots — Both bots run the same state machine with the same parameters. They peek for the same duration, fire the same burst count, and retreat at the same speed. To make P1 easier/harder, adjust its AI_REACTION or bullet count independently.
💡 Tip: The AI state machine tick runs independently from the physics update. This means AI decisions can't corrupt physics state — a key lesson from step 1's pendingReset bugs.
// Parameterized state machine — reused for both P1 and P2
// runStateMachine(sprite, group, state, timer, lastPeekIdx,
// peekSpots, peekTargetX, bulletsToFire, peekReady,
// hideX, peekXs, dir) → {state, timer, ...}
// P2 (red, right side) — AI mode + bot mode
var {aiState, aiTimer, lastPeekIdx, peekTargetX,
bulletsToFire, aiPeekReady} =
runStateMachine(this.p2, this.b2, ...,
920, [420, 600, 780], -1);
// P1 (blue, left side) — bot mode only
var {p1aiState, p1aiTimer, ...} =
runStateMachine(this.p1, this.b1, ...,
100, [180, 280, 380], 1);
// Hide → Peek transition
case 'hide':
moveTowardXY(sprite, hideX, 600 + rand*60);
timer += 100;
if (timer > 600 + rand*800) {
// Pick next peek spot — never repeat
do { idx = floor(rand*3); } while (idx === lastPeekIdx);
state = 'peek';
bulletsToFire = 1 + floor(rand*3);
}// Static group for 3 cover crates — generates 'crate' texture
this.crates = this.physics.add.staticGroup();
[200, 510, 880].forEach(function(x, i) {
var y = [180, 645, 400][i];
var c = this.crates.create(x, y, 'crate');
c.setTint(0x5c4a3a); c.refreshBody();
}, this);
// Bullets absorbed by crates — overlap, not collider
this.physics.add.overlap(this.b1, this.crates,
this.onBulletCrate, null, this);
this.physics.add.overlap(this.b2, this.crates,
this.onBulletCrate, null, this);
// Player movement blocked — full collider
this.physics.add.collider(this.p1, this.crates);
this.physics.add.collider(this.p2, this.crates);
// Bullet-crate hit: destroy bullet, flash crate brown
onBulletCrate(bullet, crate) {
bullet.setActive(false).setVisible(false);
if (bullet.body) bullet.body.enable = false;
crate.setTint(0x8b7355); // lighter = hit feedback
this.time.delayedCall(60, () => {
if (crate.active) crate.setTint(0x5c4a3a);
});
}// Mode detection from URL parameters
var params = new URLSearchParams(window.location.search);
var mode = params.get('mode');
if (mode === 'ai') this.mode = 'ai';
else if (mode === 'bot') this.mode = 'bot';
else this.mode = 'pvp';
// ?mode=ai — P2 runs state machine, P1 is human
if (this.mode === 'ai') {
this.aiState = 'hide';
this.aiTimer = 0;
this.time.addEvent({ delay: 100, loop: true,
callback: this.aiTick, callbackScope: this });
}
// ?mode=bot — both players run mirrored state machines
if (this.mode === 'bot') {
this.aiState = 'hide';
this.aiTimer = 0;
this.p1aiState = 'hide';
this.p1aiTimer = 0;
this.p1lastPeekIdx = -1;
this.p1peekSpots = [250, 380, 600];
this.p1bulletsToFire = 0;
this.p1peekTargetX = 380;
this.p1aiPeekReady = 0;
this.time.addEvent({ delay: 100, loop: true,
callback: this.aiTick, callbackScope: this });
}Destructible Cover
CompletePrompt used
Show prompt — Step 3
Build Step 3 of the Gun Fight game: DESTRUCTIBLE COVER Take the existing game at gun-fight-02.html and add destructible cover. ADDITIONS to Step 2: - 3 crates (brown, 60x60) at x=200, y=570 and x=510, y=570 and x=880, y=570 - Each crate has 3 HP (hit points) - Each bullet hit reduces HP by 1 - 3 visual states: full, cracked, destroyed - Crate HP displayed as small text above each crate - When destroyed: debris particles + camera shake - AI pathing still routes above crates (y=540) - Preserve all existing features - Canvas: 1020x720 with Phaser.Scale.FIT and CENTER_BOTH
Why learn this?
Destructible cover introduces environmental persistence and degradation. Unlike static obstacles, destructible cover forces both the player and AI to adapt as the battlefield changes. The cover you hide behind in the first exchange may not exist by the third. This teaches per-object state tracking, multi-visual-state rendering, and the strategic depth of dynamic environments.
What you built
Three crates that start with 3 HP each. Each hit reduces HP by 1, cycling through 3 visual states. When destroyed, the crate emits debris particles and triggers a camera shake. Physics body is disabled so bullets and players pass through. HP text above each crate keeps the player informed.
Variants (A/B testing)
⚡ Correct A/B protocol applied: Identical prompt, identical specs, only the model changed.
v1 — DeepSeek V4 Flash (winner)
Rich implementation with 12 debris particles, camera shake (150ms), explosion flash overlay, white-flash hit feedback, color-coded HP labels. 21.5KB, 577 lines.
▶ Play v1 (Flash)v2 — GPT-5.5-instant-14k
Clean implementation with 3-HP crates, 3 visual states, HP labels, basic fade-out on destruction. 19.6KB, 515 lines. No debris particles or screen shake despite identical specs.
▶ Play v2 (GPT)Key concepts
- Per-object state tracking — Each crate has its own HP via
setData('hp', 3). Decrement on hit, check threshold for visual transitions. - Physics body lifecycle —
body.enable = falseremoves a crate from collision without destroying the sprite. - Debris particles — 12 rectangles spawned at crate position with randomized velocity and gravity, fading over 1.5s.
- Camera shake —
this.cameras.main.shake(150, 0.006)adds tactile feedback on destruction.
Design decisions & tradeoffs
- 3 HP — Means 3-6 bullets to destroy a crate. More would make cover too durable, less makes it irrelevant.
- Color states — Brown → gold → red communicates damage intuitively.
- No crate repair — Permanent destruction adds strategic pressure.
Performance notes
- Same physics body count as Step 2 (3 static crates + 2 players + bullets).
- Debris particles (~12 sprites) are short-lived and auto-destroyed.
- HP text is 3
this.add.text()calls — negligible overhead.
⚠️ Common pitfalls
- Post-destruction overlap — Guard with
if (!crate.active || !crate.body.enable) return;. - Particle cleanup — Destroy debris sprites after their tween completes.
- Round reset —
scene.restart()handles this naturally.
💡 Tip: Remove particles from update() after destruction. Particles that exist solely for visual effect don't need per-frame processing once their tween starts.
function damageCrate(crate) {
let hp = crate.getData('hp') - 1;
crate.setData('hp', hp);
if (hp > 0) {
crate.setTint(hp === 2 ? 0xcc8844 : 0xcc3333);
} else {
crate.body.enable = false;
crate.setAlpha(0.3).setTint(0x666666);
for (let i = 0; i < 12; i++) {
let p = this.add.rectangle(
crate.x, crate.y, 6, 6, 0x8b7355);
this.tweens.add({
targets: p, alpha: 0,
y: p.y - 100 - Math.random()*100,
duration: 1500,
onComplete: () => p.destroy()
});
}
this.cameras.main.shake(150, 0.006);
}
}// HP-based tint + physics disable on destruction
function applyCrateVisual(crate) {
let hp = crate.getData('hp');
if (hp === 3) {
crate.setTint(0x8B6914); // brown = full
} else if (hp === 2) {
crate.setTint(0xDAA520); // gold = cracked
} else if (hp === 1) {
crate.setTint(0xCC3333); // red = destroyed soon
} else {
crate.body.enable = false; // physics off
crate.setAlpha(0.2); // ghost remnant
}
}function spawnDebris(scene, x, y) {
for (let i = 0; i < 12; i++) {
let p = scene.add.rectangle(
x + (Math.random() - 0.5) * 40,
y + (Math.random() - 0.5) * 40,
6 + Math.random() * 4, // random width
4 + Math.random() * 6, // random height
0x8B7355 // wood color
);
scene.tweens.add({
targets: p,
x: p.x + (Math.random() - 0.5) * 160, // spread
y: p.y - 120 - Math.random() * 80, // upward arc
angle: Math.random() * 360, // tumble
alpha: 0,
duration: 1200 + Math.random() * 600,
ease: 'Quad.easeOut',
onComplete: () => p.destroy()
});
}
}Sound & Polish
CompletePrompt used
Show prompt — Step 4
Build Step 4 of the Gun Fight game: SOUND & POLISH
Why learn this?
Sound design and visual polish separate a functional game from a memorable experience. Procedural audio via the Web Audio API teaches you to generate sound effects without any asset files. Health bars introduce per-entity state visualization, while hit feedback teaches kinesthetic response design: giving the player tactile feedback for every action.
What you built
Seven procedurally generated sound effects, per-player health bars (5 HP each), muzzle flash on every shot, screen shake and particle bursts on hit, and animated score text. Each bullet reduces HP by 1; only a kill (HP ≤ 0) increments the score. All Step 3 features preserved.
Variants (A/B testing)
v1 — DeepSeek V4 Preview (Nexum Router) — 29KB [WINNER]
[WINNER] Most compact (771 lines). All 7 spec sounds, 5 HP, clean muzzle flash, hit particles, score yoyo. Only model that followed spec correctly on first pass.
▶ Play v1 (Preview — winner)v2 — DeepSeek V4 Flash (Nexum Router) — 34KB
Most feature-rich (1029 lines). M-key mute toggle, KILL! popups. Deviated from HP spec (3 HP instead of 5). All 7 sounds.
▶ Play v2 (Flash)v3 — Xiaomi Mimo 2.5 (Nexum Router) — 33KB
Solid (923 lines). 6 sounds, 5 HP, +1 P1/+1 P2 score popups. Good but less polished.
▶ Play v3 (Mimo)Key concepts
- Web Audio API procedural sound — OscillatorNode + GainNode produce all sounds. No external files. AudioContext created lazily.
- Per-entity health system — Each player tracks HP independently. Score only on kill (HP ≤ 0).
- Kinesthetic feedback chain — Hit ↠ white flash ↠ camera shake ↠ particles ↠ score update.
Design decisions & tradeoffs
- 5 HP per kill — Makes each kill feel earned but increases match length 5x. Flash's 3 HP is faster.
- Procedural audio over files — Zero assets, zero network requests. Less richness but fine for retro.
- Score on kill, not hit — HP reduction gives feedback without flooding scoreboard.
Performance notes
- Health bars are Graphics objects updated once per frame — near-zero overhead.
- Muzzle flash exists for 80ms, then alpha = 0.
- AudioContext creates short-lived OscillatorNode instances that self-terminate.
var SoundManager = {
ctx: null,
ensureResumed() {
if (!this.ctx) {
this.ctx = new (window.AudioContext
|| window.webkitAudioContext)();
}
if (this.ctx.state === 'suspended')
this.ctx.resume();
},
playGunshot() {
this.ensureResumed();
var osc = this.ctx.createOscillator();
var gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 800;
gain.gain.setValueAtTime(0.3,
this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001,
this.ctx.currentTime + 0.02);
osc.connect(gain)
.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.02);
}
};// Per-player health bars drawn with Phaser.Graphics // Each player has 5 HP, bar width = HP * 20 (100px max) function createHealthBars(scene) { // Background bars (dark outline) scene.p1BarBg = scene.add.rectangle(20, 20, 104, 14, 0x333333); scene.p2BarBg = scene.add.rectangle(900, 20, 104, 14, 0x333333); scene.p1BarBg.setOrigin(0, 0); scene.p2BarBg.setOrigin(0, 0); // Fill bars (colored) scene.p1Bar = scene.add.rectangle(22, 22, 100, 10, 0x33aa33); scene.p2Bar = scene.add.rectangle(902, 22, 100, 10, 0xaa3333); scene.p1Bar.setOrigin(0, 0); scene.p2Bar.setOrigin(0, 0); } // HP to bar width mapping: hp * 20 pixels function updateHealthBar(scene, bar, hp) { bar.setSize(Math.max(0, hp * 20), 10); // Color shifts: green > yellow > red as HP drops if (hp > 3) bar.setFillStyle(0x33cc33); else if (hp > 1) bar.setFillStyle(0xcccc33); else bar.setFillStyle(0xcc3333); } // Called every frame after HP change updateHealthBar(this, this.p1Bar, this.p1hp); updateHealthBar(this, this.p2Bar, this.p2hp);
// Full hit feedback chain triggered on bullet impact
function onBulletHitPlayer(scene, target, attacker) {
if (target.getData('invulnerable')) return;
// 1. White flash overlay on entire screen
let flash = scene.add.rectangle(0, 0, 1020, 720, 0xffffff, 0.3);
flash.setOrigin(0, 0).setDepth(100);
scene.time.delayedCall(60, () => flash.destroy());
// 2. Camera shake — 120ms at 0.008 intensity
scene.cameras.main.shake(120, 0.008);
// 3. Hit particles burst — 8 red rectangles fly outward
for (let i = 0; i < 8; i++) {
let p = scene.add.rectangle(
target.x, target.y, 4, 4, 0xff4444);
scene.tweens.add({
targets: p,
x: p.x + (Math.random() - 0.5) * 120,
y: p.y - 40 - Math.random() * 60,
alpha: 0, angle: Math.random() * 360,
duration: 400 + Math.random() * 200,
onComplete: () => p.destroy()
});
}
// 4. Score yoyo — scale up 1.5x then back
let txt = (attacker === scene.p1)
? scene.score1 : scene.score2;
scene.tweens.add({
targets: txt,
scaleX: 1.5, scaleY: 1.5, duration: 100,
yoyo: true, ease: 'Quad.easeOut'
});
}Complete Game
CompletePrompt used
Show prompt — Step 5
Build Step 5 of the Gun Fight game: COMPLETE GAME Extend Step 4 (Sound & Polish) with: 1. Main Menu screen — "GUN FIGHT" title, starfield, mode buttons 2. Round system — best of 5, round intros with countdown 3. Match end screen — round breakdown, confetti, rematch/menu 4. Power-ups — health (+3 HP), rapid fire (150ms, 5s), shield (1 hit, 5s) 5. Death/respawn animations — scale+fade, 800ms delay, camera zoom 6. Parallax background — mountains, cacti, ground layers 7. Game flow: Menu → Round Intro → Gameplay → Match End Preserve ALL existing features: AI state machine, destructible crates, health bars, sound effects, muzzle flash, hit feedback. Canvas: 1020x720, Phaser.Scale.FIT, CENTER_BOTH
Why learn this?
Step 5 brings together everything into a complete game. You learn game state management (menu → gameplay → end screen), multi-scene architecture (separate Phaser scenes for menu and gameplay), round-based competition systems, power-up design (spawning, duration, stacking), and visual polish that transforms a tech demo into a shipped title. The parallax background introduces layered rendering, and the match-end screen teaches persistent state across rounds.
What you built
A complete western duel game: main menu with animated title and starfield, best-of-5 round system with 3-2-1 countdown intros, 3 power-up types (health restore, rapid fire, shield), death/respawn animations with camera zoom, a parallax desert background (sky, mountains, cacti, ground), and a match-end screen with round-by-round breakdown and confetti celebration. All 1,688 lines of Phaser.js.
Variants (A/B testing)
v1 — DeepSeek V4 Preview (Nexum Router) — 62KB [WINNER]
[WINNER] Only model that followed the spec completely. Full main menu, best-of-5 rounds, 3 power-ups, death/respawn animations, 4-layer parallax background, match-end confetti, round summaries. 1,688 lines.
▶ Play v1 (Preview — winner)v2 — DeepSeek V4 Flash (Nexum Router) — 34KB
Focused on different features: localStorage high scores, stats overlay, kill popups, mute toggle. Did NOT implement main menu, round system, power-ups, or parallax. Interesting tangential features but missed the spec.
▶ Play v2 (Flash)v3 — Xiaomi Mimo 2.5 (Nexum Router) — 34KB
Added kill feed popups, name tags, bullet clash, spawn shield, winner flair. Did NOT implement main menu, round system, power-ups, parallax, or death animations. Missed the core spec.
▶ Play v3 (Mimo)Key concepts
- Multi-scene architecture — Separate Phaser scenes for menu (
MainMenuScene) and gameplay (GunFightScene).this.scene.start('GunFightScene')transitions between them cleanly. - Round state management — Track round wins per player, round history array, current round number. Display intermission screens between rounds.
- Power-up spawning — Timed spawns at random positions avoiding crate colliders. Each type has its own visual, duration, and stacking logic.
- Parallax depth layers — 4 layers rendered via
Phaser.Graphics: sky gradient, mountain silhouettes, cacti, ground. Each scrolls at different speed based on camera midpoint. - Tween-based animations — Death uses scale+fade tweens instead of instant disable. Respawn uses Back.easeOut for bouncy feel. Confetti uses randomized velocity tweens.
Design decisions & tradeoffs
- Separate scenes vs mode flags — A separate
MainMenuSceneisolates menu logic from gameplay, avoiding state pollution. Tradeoff: need to pass mode parameter between scenes. - Best of 5 vs first to 5 — Best of 5 (first to 3 round wins) matches real fighting game conventions. Each round is a fresh gunfight to 5 kills.
- 3 power-up types — Enough variety for strategic depth without overwhelming players. Health is reactive, rapid fire is offensive, shield is defensive.
- Procedural parallax vs assets — Graphics-drawn background means zero asset files. Tradeoff: less visual richness than pre-rendered sprites.
Performance notes
- 1,688 lines, 62KB — the largest file in the series but still loads instantly.
- Parallax background is drawn once in
create(), not per-frame — minimal overhead. - Power-up timer uses Phaser's
time.addEvent— no per-frame polling. - Confetti particles auto-destroy after animation. No persistent object leak.
💡 Tip: Multi-scene architecture makes it easy to add new screens (options, tutorial, credits) without touching gameplay code. Each scene is a self-contained state.
// MainMenuScene — title screen with mode selection
class MainMenuScene extends Phaser.Scene {
create() {
this.add.text(510, 100, 'GUN FIGHT', {
fontSize: '64px', color: '#ff6b35',
fontFamily: 'monospace', fontStyle: 'bold'
}).setOrigin(0.5);
// Starfield particles, cowboy silhouettes
// Mode buttons: PvP, Vs AI, Bot Watch
// ENTER or click to start
this.input.keyboard.on('keydown-ENTER',
() => this.startGame('pvp'));
}
startGame(mode) {
this.scene.start('GunFightScene', { mode });
}
}
// GunFightScene receives mode via scene data
class GunFightScene extends Phaser.Scene {
init(data) {
this.mode = data.mode || 'pvp';
}
}// Power-up type variants with visual + effect
const POWERUP_TYPES = [
{ type: 'health', color: 0x44ff44, label: '+HP' },
{ type: 'rapid', color: 0xffaa00, label: 'RF' },
{ type: 'shield', color: 0x44aaff, label: 'SH' }
];
// Spawn timer — every 8-12 seconds at random open pos
this.time.addEvent({
delay: 8000 + Math.random() * 4000,
loop: true,
callback: () => {
let pos = this.findOpenPosition();
let t = POWERUP_TYPES[Phaser.Math.Between(0, 2)];
let pu = this.add.circle(pos.x, pos.y, 12, t.color);
this.physics.add.existing(pu);
pu.powerType = t.type;
this.time.delayedCall(6000, () => {
if (pu.active) pu.destroy();
});
}
});
// Overlap — player collects power-up
this.physics.add.overlap(this.p1, this.powerUps,
(player, pu) => {
pu.destroy();
switch (pu.powerType) {
case 'health':
player.hp = Math.min(player.hp + 3, 5);
break;
case 'rapid':
player.fireRate = 150;
player.rapidTimer = 5000;
break;
case 'shield':
player.shield = true;
player.shieldTimer = 5000;
break;
}
}, null, this);
// In update(): countdown rapid fire & shield timers
if (player.rapidTimer > 0) {
player.rapidTimer -= delta;
if (player.rapidTimer <= 0)
player.fireRate = 350; // restore default
}
if (player.shieldTimer > 0) {
player.shieldTimer -= delta;
if (player.shieldTimer <= 0) player.shield = false;
}// Parallax background — drawn once in create()
// Each layer scrolls at a different speed
function drawParallax(scene) {
var g = scene.add.graphics();
// Layer 1: Sky gradient (static — no scroll)
g.fillGradientStyle(
0x1a0a2e, 0x1a0a2e, 0x4a2080, 0x4a2080, 1);
g.fillRect(0, 0, 1020, 720);
// Layer 2: Mountains (scroll factor 0.2x)
g.fillStyle(0x2a1545, 0.8);
for (var x = -100; x < 1120; x += 200) {
g.fillTriangle(
x - 80, 500, x + 80, 500, x, 320);
}
// Layer 3: Cacti silhouettes (scroll factor 0.5x)
g.fillStyle(0x1a3020, 0.9);
[150, 380, 620, 850].forEach(cx => {
g.fillRect(cx - 3, 600, 6, -60); // trunk
g.fillRect(cx - 15, 555, 12, 6); // left arm
g.fillRect(cx + 3, 565, 12, 6); // right arm
});
// Layer 4: Ground (scroll factor 1.0x)
g.fillStyle(0x3a2510, 1);
g.fillRect(0, 650, 1020, 70);
g.fillStyle(0x5a3a1a, 0.6);
g.fillRect(0, 650, 1020, 3);
}
// In update(): scroll offset from camera midpoint
var cx = (this.p1.x + this.p2.x) / 2;
var scrollX = Phaser.Math.Clamp(cx - 510, 0, 200);
this.bgLayers.forEach((layer, i) => {
layer.x = -scrollX * [0, 0.2, 0.5, 1.0][i];
});Lessons Learned — Build Process
All issues encountered during Step 1 (Two Cowboys), from blank canvas to full game loop. Chronological.
__DEFAULT Texture Key — Blank Canvasphysics.add.sprite(x, y, "__DEFAULT") uses a texture key that doesn't exist in Phaser. The sprite is created but renders invisible. Pong/Breakout work because they call generateTexture() first. Fix: use this.add.rectangle() + this.physics.add.existing() for shapes, or generate all textures once in create().
this Scope in Standalone FunctionsfireBullet() is a standalone function. this.textures.exists(), this.add.graphics() inside it reference the global object, not the Phaser scene. The bullet texture never generates; bullets are invisible. Fix: generate all textures once in create() where this is the scene. Close over references in callback closures.
Scale.FIT — Tiny CanvasNo scale config in Phaser.Game config. Canvas renders at raw 680x480 regardless of viewport. Tiny on large screens. Fix: add scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }.
gameOverPhysics overlap callbacks fire during the physics step, which happens before update(). Even with if (gameOver) return; at the top of update(), a bullet that's mid-flight when the winning point scores can still trigger the collision callback on the next frame, incrementing the score past the win limit. Fix: guard EVERY collision callback with if (... || gameOver) return;.
body.enable=false vs setVelocity(0,0)Setting velocity to 0 doesn't stop physics processing. The body still checks collisions and responds to forces. When gameOver = true, setting setVelocity(0,0) alone lets players drift or respond to residual overlap events. Fix: player.body.enable = false disables the body entirely — no further physics processing, no collision callbacks.
The first bot implementation only set P2's velocity and fired from P2's bullet group. P1 had no bot controller, so in bot mode P1 stood still while P2 moved and fired. Fix: botAI() controls both players independently — each with strafe movement and fire-toward-opponent logic.
Superseded by fix in lesson 11. Original: resetPositions() instantly teleported both players with no context. Added flash effect via setAlpha(0.3) + delayed call. This was later replaced by instant position reset in sync() — no timers, no alpha manipulation — when the delayed approach caused invisible-player and frozen-input bugs.
When a player reached 5 points, the game froze with no indication of how to continue. The R key already restarted, but players didn't know. Fix: txtW.setText("BLUE WINS! [R] Rematch") with explicit action prompt. Combined with scene.restart() on keydown-R for clean state reset.
DeepSeek V4 Flash produced 4 partial rewrites, each fixing some bugs but introducing new ones. The iteration tax accumulated faster than the bug fix rate. Switching to DeepSeek V4 Pro produced a clean, working game in one pass. Fix: after 2 failed rewrites on the same step, switch from Flash to Pro. 3x cost << iteration tax of 4+ buggy rewrites.
Created scripts/verify-game-step.py after the build to catch these issues automatically: script tag balance, Phaser.Game instantiation, __DEFAULT detection, Scale.FIT, embedded line numbers, JustDown for fire, bot mode link, stats table presence, gameOver guards, player container, and </style> balance. Run after every step before commit.
clear(true,true) in Collision Callback — Score CorruptionCalled b1.clear(true,true) inside the overlap callback to reset bullets. This destroyed group children while Phaser's physics iterator was mid-iteration, corrupting the pair loop. Result: the same collision fired 5+ times per hit, immediately reaching WIN=5 and ending the game on the first hit. Fix: never call clear() or destroy() inside an overlap callback. Only setActive(false) the specific colliding object. Deactivate other bullets via forEach(deactivate), not clear().
The original game used standalone create()/update() functions with IIFE-scoped vars. Collision callbacks captured closure references, and this inside them was the global object, not the scene. After 4+ failed inline patches, rewrote as class GunFightScene extends Phaser.Scene in a separate gun-fight.js file. All state is on this, collision callbacks use callbackScope: this (5th arg to overlap()), and scene.restart() cleans up without race conditions. Lesson: class-based scenes with callbackScope eliminate the scope-entanglement bugs that inline IIFE patterns breed.
Added ?ai mode (human vs AI) using Pong's tracking pattern. The AI's velocity calculation had the ternary inverted: diff < 0 (P1 is left of P2) should move AI left (negative velocity), but the code gave positive velocity (right, away from P1). AI stood still because it immediately hit the right-side clamp. Fix: diff > 0 ? SPD : -SPD — test each movement direction against a known starting position before calling AI done.
Every remaining bug traced to one pattern: modifying physics state inside overlap callbacks. Setting body.enable = false, calling setPosition(), or changing velocity during Phaser's physics pair iteration corrupts the pair list. This caused score inflation (WIN=5 on first hit), invisible sprites, and frozen bodies. Fix: never move sprites or modify bodies inside collision callbacks. Set a pendingReset flag on hit, apply resets in update() after the physics step completes. Pong avoids this because it scores via worldbounds events, not overlap callbacks.
pendingReset Must Undo ALL Three Disabled StatesThe pendingReset flag fixed the immediate physics corruption but introduced its own bug: onHitP1/2 disables THREE things (setActive(false), setVisible(false), body.enable = false), but pendingReset only restored position + alpha. setAlpha(1) does NOT undo setVisible(false). Symptoms: PvP — player disappears after death, invisible body. AI mode — player never respawns, score stuck at 0-1. Bot mode — both AIs stall at 1-1. The if (mode === 'pvp') guard meant P2 in AI/bot mode was never reset at all. Fix: pendingReset now unconditionally re-enables all three on BOTH players — setActive(true), setVisible(true), body.enable = true — plus position, velocity, and alpha. Verified by 42-assertion Playwright test suite against the live game.
Directional peeking fix: The original AI used getCoverPosition() to find the nearest crate, then strafed randomly left or right in PEEK state. When the AI strafed right, it stayed behind cover (wasting the peek). When it strafed left, it hit the crate's staticGroup collider and got pinned — the physics body prevented movement but the state timer kept running, cycling HIDE→PEEK→HIDE without ever reaching a firing position. Fix: replaced dynamic cover navigation with fixed target positions per state: HIDE at x=920, PEEK at x=530, RETREAT at x=920. The moveToward(tx, ty) helper always moves toward a fixed goal — no crate collision fights, no random direction.
Vertical lock fix: After the directional fix, all three states still targeted y=630 exclusively via moveToward(stateX, 630). The AI never moved up or down — it was locked to a single Y coordinate behind the crates. Combined with the crate collider, this made the AI look like it was glued to the floor. Fix: added aiTargetY with pickYTarget() — picks a random Y between 580–680 on each state entry and periodically (every 400–1100ms) within states. PEEK now has vertical strafing, HIDE has vertical shuffling, RETREAT has vertical diversion. The AI no longer fights the crate collider to return to y=630; instead it adapts its vertical position randomly, making it harder to predict and hit.
Lesson for step 2: Dynamic navigation around physics obstacles is deceptively hard for hand-coded AI. The crate colliders create edge cases (pinning, vibration, stuck states) that are hard to predict without a full physics simulation. Fixed target positions + random vertical offset produce more reliable behavior than trying to navigate cover dynamically. A DQN agent would learn to navigate cover naturally — hand-coded state machines work better with abstraction layers (goals, not physics paths).
Context: After the directional and vertical lock fixes, the AI still moved back and forth between two fixed positions (hide at 920, peek at 530). The player could pre-aim at x=530 and kill the AI every time it peeked. The crate positions were also poorly placed — left crate at x=250 cramped the player, and the peek target x=530 was adjacent to the center crate at x=510, making the AI look like it was fighting the crate even though it wasn't colliding with it.
Fix — Position-aware AI: Abandoned the "make AI navigate around crates" approach entirely. Instead, the AI always routes above crate height (y=540, above crate tops at y=570) when moving horizontally, then drops behind cover at its destination. The AI-crate collider remains active — the AI simply never walks into crates because horizontal movement happens above them. 3 peek positions [420, 600, 780] with a "never repeat the same spot" rule, burst fire (1-3 shots per peek), and crates repositioned to [330, 510, 690] for natural flanking lanes.
Design principle: When AI navigation around obstacles fails, route movement above them. In a side-scroller, "above" is a valid navigable path — the AI climbs over crates. The collider still prevents horizontal walking through crates at ground level, but the AI never walks at ground level during transitions.
What went wrong: The A/B comparison between Flash and Mimo v2.5 was set up incorrectly. "A/B testing" was interpreted as "build each step independently with different prompts" instead of "build the same step with the same prompt using different models." This meant Flash step 1 and Mimo step 1 had different prompts, different specs (680x480 vs 1020x720), and different instructions — making any comparison meaningless. The same mistake repeated for step 2.
Secondary failure: Over-confidence in headless browser diagnostics. Pixel-read tests in the headless browser (SwiftShader software GPU) returned pure black for ALL Phaser WebGL games — including the known-working Flash versions. This led to unnecessary "fixes" on a working Mimo step 2 file, which broke it and had to be reverted. The headless browser is not a valid test environment for Phaser WebGL rendering.
Correct A/B protocol: Identical prompt, identical specs, only the model changes. Verify in a real browser, not headless. Never touch a working file without user confirmation.
Reference files preserved: Mimo v2.5 builds kept at gun-fight-01-mimo.html (non-functional — see root cause above) and gun-fight-02-mimo.html (functional — same layout, independent implementation).