diff --git a/oh-my-claudecode/DESIGN.md b/oh-my-claudecode/DESIGN.md
new file mode 100644
index 0000000..4d166e7
--- /dev/null
+++ b/oh-my-claudecode/DESIGN.md
@@ -0,0 +1,47 @@
+# OMC Neon Arena — Design Document
+
+## Concept
+A top-down arcade survival shooter rendered on HTML5 canvas. The player controls a glowing "agent" orb, surviving endless waves of enemy "bugs." Built as a demo for **oh-my-claudecode** multi-agent orchestration — each subsystem was authored by a specialized teammate.
+
+## Tech Stack
+- **No build step.** Pure HTML + CSS + vanilla JS (ES modules).
+- Canvas 2D for rendering, Web Audio API for procedural SFX, `localStorage` for high-score persistence.
+- Served directly — open `index.html` in a browser or use a static server.
+
+## File Layout (target)
+```
+index.html Entry HTML, canvas, HUD DOM
+styles/main.css Neon theme, HUD, overlays, responsive
+src/main.js Bootstraps engine, game, UI
+src/engine/loop.js Fixed-timestep game loop
+src/engine/input.js Keyboard + pointer input
+src/engine/entity.js Entity / component base
+src/game/player.js Player movement, shooting, health
+src/game/enemy.js Enemy types + AI
+src/game/waves.js Wave spawner, difficulty curve
+src/game/bullet.js Projectiles
+src/game/particles.js Particle FX
+src/game/audio.js Procedural Web Audio SFX
+src/ui/hud.js Score, health, wave
+src/ui/menu.js Start, pause, game-over overlays
+src/ui/highscore.js localStorage high-score
+README.md Project-level intro
+```
+
+## Gameplay
+- **Controls:** WASD/arrows move, mouse aim, click/space shoot, P pause.
+- **Waves:** Increasing enemy counts and speeds. Three enemy types: chaser (fast/weak), bruiser (slow/tanky), splitter (splits on death).
+- **Scoring:** Per-kill points scaled by wave. Combo multiplier when kills chain within 2s.
+- **Death:** Overlay with score, best score, restart.
+
+## Visual Language
+- Dark background with subtle grid.
+- Neon cyan/magenta/yellow palette; glow via `shadowBlur`.
+- Screen shake on hits, particles on explosions.
+
+## Task Decomposition
+1. Scaffold + engine core (index.html, engine loop, input, entities).
+2. Gameplay: player, enemies, bullets, waves, collisions.
+3. UI/HUD: CSS theme, HUD overlay, menu/pause/game-over screens.
+4. Audio + particles polish.
+5. README with a "built with OMC" section.
diff --git a/oh-my-claudecode/README.md b/oh-my-claudecode/README.md
index 1c6cc19..81ed408 100644
--- a/oh-my-claudecode/README.md
+++ b/oh-my-claudecode/README.md
@@ -1 +1,124 @@
-# try-oh-my-claudecode
\ No newline at end of file
+# OMC Neon Arena
+
+A top-down arcade survival shooter built with **oh-my-claudecode** multi-agent orchestration. Survive endless waves of neon enemies, chase the combo multiplier, and compete for the high score.
+
+## Built with oh-my-claudecode
+
+This entire game was designed and implemented by a specialized multi-agent OMC team:
+
+- **worker-1**: Engine & scaffold (HTML5 canvas, fixed-timestep loop, input handling)
+- **worker-2**: Gameplay systems (player, enemies, bullets, waves, collision, scoring)
+- **worker-3**: UI & visual theme (HUD, menus, neon CSS aesthetic)
+- **worker-4**: Documentation (this README)
+
+**Pipeline**: strategic planning → parallel execution (engine + gameplay + UI in lockstep) → verification → release.
+
+## How to Run
+
+No build step. Open the game in your browser:
+
+**Option 1:** Use a static server
+```bash
+# Python 3
+python -m http.server
+
+# Or Node.js
+npx serve
+```
+
+Then visit `http://localhost:8000` (or the port shown).
+
+**Option 2:** Open directly
+Open `index.html` in your browser. (Note: Chrome and modern browsers may block module imports from `file://` — use a server for best compatibility.)
+
+## Controls
+
+| Action | Key |
+|--------|-----|
+| Move | **WASD** or **Arrow Keys** |
+| Aim | **Mouse** |
+| Shoot | **Click** or **Space** |
+| Pause | **P** or **Escape** |
+
+## Gameplay
+
+### Player
+- **Health**: 3 HP per run
+- **Speed**: 260 px/s
+- **Shoot Cooldown**: 140ms (≈7 shots/s)
+- **Bullet Speed**: 520 px/s
+- **Invulnerability**: 0.9s after hit
+
+### Enemy Types
+
+| Type | HP | Speed | Color | Score |
+|------|----|----|-------|-------|
+| **Chaser** | 1 | 110 px/s | Magenta | 10 pts |
+| **Bruiser** | 4 | 55 px/s | Yellow | 40 pts |
+| **Splitter** | 2 | 90 px/s | Lime | 25 pts → splits into 2 **Minis** (1 HP, 5 pts each) |
+
+All enemies seek the player. Splitters explode into two faster mini-enemies when defeated.
+
+### Waves
+
+| Wave | Contents | Spawning |
+|------|----------|----------|
+| **1** | 8 chasers | 0.35s stagger |
+| **2** | 10 chasers + 2 bruisers | 0.35s stagger |
+| **3+** | Scales: 8 + wave×2 chasers, floor(wave/2) bruisers, max(0, wave−2) splitters | 0.35s stagger |
+
+Between waves: 2 second intermission to catch your breath.
+
+### Scoring & Combos
+
+- **Base Points**: Enemy score × combo multiplier
+- **Combo Multiplier**: 1 + floor(combo/5)
+ - Earn 1 combo per kill
+ - Combo resets if you take 2+ seconds without a kill
+ - Max multiplier at 5 kills = 2×, at 10 kills = 3×, etc.
+- **Example**: Kill a bruiser (40 pts) with 7 combo stacks → 40 × (1 + floor(7/5)) = 40 × 2 = 80 pts
+
+## Architecture
+
+The game uses a modular, file-scoped design to allow parallel development:
+
+```
+src/
+├── main.js Entry point, bootstraps engine + game + UI
+├── engine/
+│ ├── loop.js Fixed-timestep game loop, render loop, pause state
+│ ├── input.js Keyboard (WASD/arrows) + pointer input
+│ └── entity.js Base Entity class, EntityPool (free-list allocator)
+├── game/
+│ ├── game.js Game state machine, collision dispatch, scoring/combo logic, shake FX
+│ ├── player.js Player movement, shooting, health, knockback, invulnerability
+│ ├── enemy.js Enemy types (chaser, bruiser, splitter, mini), pathfinding
+│ ├── waves.js Wave scheduler, difficulty curve, enemy queue
+│ ├── bullet.js Projectile entity, lifetime, collision tag
+│ ├── particles.js Particle emitter system, pools, lifetime decay
+│ ├── collision.js Broad/narrow phase collision resolution
+│ └── audio.js Procedural Web Audio SFX (shoot, explode, hurt, wave, gameover)
+└── ui/
+ ├── hud.js Score, health, wave, combo display updates
+ ├── menu.js Start, pause, game-over overlay state
+ └── highscore.js localStorage high-score persistence
+
+styles/
+└── main.css Neon cyan/magenta/yellow palette, glows, overlays, responsive layout
+
+index.html Canvas host, HUD/menu/overlay DOM, ES module entry
+```
+
+## What OMC Demonstrated
+
+- **Parallel Task Decomposition**: Three subsystems (engine, gameplay, UI) developed independently without merge conflicts.
+- **Specialist Routing**: Each agent played to its strengths — engine builder, gameplay designer, UI/visual specialist.
+- **File-Scoped Isolation**: Strict module boundaries prevented stepping on toes; no shared mutable state between lanes.
+- **Dependency Ordering**: Engine completed first, gameplay built on top, UI integrated last — critical path was visible and managed.
+- **Verification Gates**: Each subsystem verified before integration; final pass checked gameplay balance and visual polish.
+
+This is a proof-of-concept that multi-agent orchestration scales to interactive, real-time applications.
+
+## License
+
+Unlicensed. Built as a demo for oh-my-claudecode.
\ No newline at end of file
diff --git a/oh-my-claudecode/index.html b/oh-my-claudecode/index.html
new file mode 100644
index 0000000..b709303
--- /dev/null
+++ b/oh-my-claudecode/index.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+ OMC Neon Arena
+
+
+
+
+
+
+
+
Score: 0
+
Wave: 1
+
HP: 100
+
Combo: x1
+
+
+
+
+
+
+
Paused
+
+
+
+
+
+
+
+
You Died
+
Score: 0
+
Best: 0
+
+
+
+
+
+
+
+
diff --git a/oh-my-claudecode/src/engine/canvas.js b/oh-my-claudecode/src/engine/canvas.js
new file mode 100644
index 0000000..3e161f1
--- /dev/null
+++ b/oh-my-claudecode/src/engine/canvas.js
@@ -0,0 +1,67 @@
+// Canvas bootstrap: device-pixel-ratio aware resize, exposes a stable logical size.
+
+const LOGICAL_WIDTH = 1280;
+const LOGICAL_HEIGHT = 720;
+
+export function createCanvas(canvasEl) {
+ if (!canvasEl) throw new Error("createCanvas: canvas element is required");
+
+ const ctx = canvasEl.getContext("2d");
+ if (!ctx) throw new Error("createCanvas: 2D context not available");
+
+ const state = {
+ canvas: canvasEl,
+ ctx,
+ width: LOGICAL_WIDTH,
+ height: LOGICAL_HEIGHT,
+ dpr: 1,
+ };
+
+ function resize() {
+ const dpr = window.devicePixelRatio || 1;
+ const rect = canvasEl.getBoundingClientRect();
+ const cssWidth = rect.width || LOGICAL_WIDTH;
+ const cssHeight = rect.height || LOGICAL_HEIGHT;
+
+ canvasEl.width = Math.round(cssWidth * dpr);
+ canvasEl.height = Math.round(cssHeight * dpr);
+
+ const scaleX = canvasEl.width / LOGICAL_WIDTH;
+ const scaleY = canvasEl.height / LOGICAL_HEIGHT;
+ const scale = Math.min(scaleX, scaleY);
+
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
+
+ state.dpr = dpr;
+ state.width = LOGICAL_WIDTH;
+ state.height = LOGICAL_HEIGHT;
+ state.cssWidth = cssWidth;
+ state.cssHeight = cssHeight;
+ state.displayScale = scale;
+ }
+
+ window.addEventListener("resize", resize, { passive: true });
+ resize();
+
+ return {
+ get ctx() {
+ return state.ctx;
+ },
+ get canvas() {
+ return state.canvas;
+ },
+ get width() {
+ return state.width;
+ },
+ get height() {
+ return state.height;
+ },
+ get dpr() {
+ return state.dpr;
+ },
+ get displayScale() {
+ return state.displayScale;
+ },
+ resize,
+ };
+}
diff --git a/oh-my-claudecode/src/engine/entity.js b/oh-my-claudecode/src/engine/entity.js
new file mode 100644
index 0000000..eaf63b9
--- /dev/null
+++ b/oh-my-claudecode/src/engine/entity.js
@@ -0,0 +1,60 @@
+// Entity base + EntityPool. Pools are not object pools (no recycling) — they
+// just own a list and expose filterAlive() for sweeping dead entities.
+
+let nextId = 1;
+
+export class Entity {
+ constructor({ x = 0, y = 0, vx = 0, vy = 0, radius = 8 } = {}) {
+ this.id = nextId++;
+ this.pos = { x, y };
+ this.vel = { x: vx, y: vy };
+ this.radius = radius;
+ this.alive = true;
+ }
+
+ update(/* dt */) {}
+ render(/* ctx */) {}
+
+ kill() {
+ this.alive = false;
+ }
+}
+
+export class EntityPool {
+ constructor() {
+ this.items = [];
+ }
+
+ add(entity) {
+ this.items.push(entity);
+ return entity;
+ }
+
+ remove(entity) {
+ const idx = this.items.indexOf(entity);
+ if (idx !== -1) this.items.splice(idx, 1);
+ }
+
+ clear() {
+ this.items.length = 0;
+ }
+
+ forEach(fn) {
+ for (let i = 0; i < this.items.length; i++) fn(this.items[i], i);
+ }
+
+ filterAlive() {
+ let write = 0;
+ for (let read = 0; read < this.items.length; read++) {
+ const item = this.items[read];
+ if (item.alive) {
+ this.items[write++] = item;
+ }
+ }
+ this.items.length = write;
+ }
+
+ get size() {
+ return this.items.length;
+ }
+}
diff --git a/oh-my-claudecode/src/engine/input.js b/oh-my-claudecode/src/engine/input.js
new file mode 100644
index 0000000..284f7a6
--- /dev/null
+++ b/oh-my-claudecode/src/engine/input.js
@@ -0,0 +1,86 @@
+// Keyboard + pointer input manager. Tracks pressed keys, mouse position in
+// canvas-logical coordinates, and mouse button state.
+
+export function createInput({ canvas, logicalWidth = 1280, logicalHeight = 720 } = {}) {
+ if (!canvas) throw new Error("createInput: canvas is required");
+
+ const keys = new Set();
+ const pressedThisFrame = new Set();
+ const releasedThisFrame = new Set();
+ const mouse = { x: logicalWidth / 2, y: logicalHeight / 2, down: false };
+
+ function onKeyDown(e) {
+ if (!keys.has(e.code)) pressedThisFrame.add(e.code);
+ keys.add(e.code);
+ if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
+ e.preventDefault();
+ }
+ }
+
+ function onKeyUp(e) {
+ keys.delete(e.code);
+ releasedThisFrame.add(e.code);
+ }
+
+ function updateMouseFromEvent(e) {
+ const rect = canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+ mouse.x = ((e.clientX - rect.left) / rect.width) * logicalWidth;
+ mouse.y = ((e.clientY - rect.top) / rect.height) * logicalHeight;
+ }
+
+ function onMouseMove(e) {
+ updateMouseFromEvent(e);
+ }
+
+ function onMouseDown(e) {
+ updateMouseFromEvent(e);
+ mouse.down = true;
+ }
+
+ function onMouseUp() {
+ mouse.down = false;
+ }
+
+ function onBlur() {
+ keys.clear();
+ mouse.down = false;
+ }
+
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener("keyup", onKeyUp);
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mousedown", onMouseDown);
+ window.addEventListener("mouseup", onMouseUp);
+ window.addEventListener("blur", onBlur);
+
+ return {
+ isDown(code) {
+ return keys.has(code);
+ },
+ wasPressed(code) {
+ return pressedThisFrame.has(code);
+ },
+ wasReleased(code) {
+ return releasedThisFrame.has(code);
+ },
+ anyDown(codes) {
+ return codes.some((c) => keys.has(c));
+ },
+ get mouse() {
+ return mouse;
+ },
+ endFrame() {
+ pressedThisFrame.clear();
+ releasedThisFrame.clear();
+ },
+ destroy() {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("keyup", onKeyUp);
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mousedown", onMouseDown);
+ window.removeEventListener("mouseup", onMouseUp);
+ window.removeEventListener("blur", onBlur);
+ },
+ };
+}
diff --git a/oh-my-claudecode/src/engine/loop.js b/oh-my-claudecode/src/engine/loop.js
new file mode 100644
index 0000000..fb88210
--- /dev/null
+++ b/oh-my-claudecode/src/engine/loop.js
@@ -0,0 +1,74 @@
+// Fixed-timestep game loop with requestAnimationFrame.
+// Caps frame delta to avoid the "spiral of death" when the tab is backgrounded.
+
+const MAX_FRAME_DT = 0.25;
+
+export function startLoop({ update, render, fixedDt = 1 / 60 } = {}) {
+ if (typeof update !== "function") throw new Error("startLoop: update is required");
+ if (typeof render !== "function") throw new Error("startLoop: render is required");
+
+ let accumulator = 0;
+ let lastTime = 0;
+ let rafId = 0;
+ let running = true;
+ let frames = 0;
+ let fpsWindowStart = 0;
+ let fps = 0;
+
+ function frame(timeMs) {
+ rafId = requestAnimationFrame(frame);
+
+ if (!lastTime) {
+ lastTime = timeMs;
+ fpsWindowStart = timeMs;
+ return;
+ }
+
+ let dt = (timeMs - lastTime) / 1000;
+ lastTime = timeMs;
+ if (dt > MAX_FRAME_DT) dt = MAX_FRAME_DT;
+
+ if (running) {
+ accumulator += dt;
+ let steps = 0;
+ while (accumulator >= fixedDt && steps < 8) {
+ update(fixedDt);
+ accumulator -= fixedDt;
+ steps += 1;
+ }
+
+ const alpha = accumulator / fixedDt;
+ render(alpha, dt);
+
+ frames += 1;
+ if (timeMs - fpsWindowStart >= 1000) {
+ fps = frames;
+ frames = 0;
+ fpsWindowStart = timeMs;
+ }
+ }
+ }
+
+ rafId = requestAnimationFrame(frame);
+
+ return {
+ pause() {
+ running = false;
+ },
+ resume() {
+ running = true;
+ lastTime = 0;
+ accumulator = 0;
+ },
+ stop() {
+ cancelAnimationFrame(rafId);
+ running = false;
+ },
+ get running() {
+ return running;
+ },
+ get fps() {
+ return fps;
+ },
+ };
+}
diff --git a/oh-my-claudecode/src/game/audio.js b/oh-my-claudecode/src/game/audio.js
new file mode 100644
index 0000000..1908c3e
--- /dev/null
+++ b/oh-my-claudecode/src/game/audio.js
@@ -0,0 +1,57 @@
+// Minimal procedural SFX. Lazy AudioContext so we don't breach autoplay rules.
+
+const VOICES = {
+ shoot: { freq: 740, freqEnd: 280, dur: 0.07, type: "square", gain: 0.05 },
+ hit: { freq: 220, freqEnd: 90, dur: 0.12, type: "sawtooth", gain: 0.08 },
+ explode: { freq: 160, freqEnd: 40, dur: 0.22, type: "triangle", gain: 0.12 },
+ hurt: { freq: 120, freqEnd: 60, dur: 0.3, type: "sawtooth", gain: 0.16 },
+ wave: { freq: 440, freqEnd: 880, dur: 0.25, type: "sine", gain: 0.09 },
+ gameover: { freq: 320, freqEnd: 60, dur: 0.9, type: "sawtooth", gain: 0.18 },
+};
+
+export class AudioBus {
+ constructor() {
+ this.ctx = null;
+ this.enabled = true;
+ }
+
+ _ensureCtx() {
+ if (this.ctx) return this.ctx;
+ const AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC) return null;
+ try {
+ this.ctx = new AC();
+ } catch {
+ this.ctx = null;
+ }
+ return this.ctx;
+ }
+
+ resume() {
+ const ctx = this._ensureCtx();
+ if (ctx && ctx.state === "suspended") ctx.resume().catch(() => {});
+ }
+
+ setEnabled(v) {
+ this.enabled = !!v;
+ }
+
+ play(name) {
+ if (!this.enabled) return;
+ const ctx = this._ensureCtx();
+ if (!ctx) return;
+ const voice = VOICES[name];
+ if (!voice) return;
+ const now = ctx.currentTime;
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = voice.type;
+ osc.frequency.setValueAtTime(voice.freq, now);
+ osc.frequency.exponentialRampToValueAtTime(Math.max(20, voice.freqEnd), now + voice.dur);
+ gain.gain.setValueAtTime(voice.gain, now);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + voice.dur);
+ osc.connect(gain).connect(ctx.destination);
+ osc.start(now);
+ osc.stop(now + voice.dur + 0.02);
+ }
+}
diff --git a/oh-my-claudecode/src/game/bullet.js b/oh-my-claudecode/src/game/bullet.js
new file mode 100644
index 0000000..6be8730
--- /dev/null
+++ b/oh-my-claudecode/src/game/bullet.js
@@ -0,0 +1,53 @@
+// Bullet: straight-line projectile rendered as a short neon tracer.
+
+import { Entity } from "../engine/entity.js";
+
+const DEFAULT_TTL = 1.2;
+
+export class Bullet extends Entity {
+ constructor(opts = {}) {
+ super({ radius: 4, ...opts });
+ this.damage = opts.damage ?? 1;
+ this.ttl = opts.ttl ?? DEFAULT_TTL;
+ this.owner = opts.owner ?? "player";
+ this.color = opts.color || "#9ff";
+ }
+
+ update(dt, game) {
+ this.pos.x += this.vel.x * dt;
+ this.pos.y += this.vel.y * dt;
+ this.ttl -= dt;
+ if (this.ttl <= 0) {
+ this.alive = false;
+ return;
+ }
+ if (game && game.canvas) {
+ const w = game.canvas.width;
+ const h = game.canvas.height;
+ if (this.pos.x < -20 || this.pos.x > w + 20 || this.pos.y < -20 || this.pos.y > h + 20) {
+ this.alive = false;
+ }
+ }
+ }
+
+ render(ctx) {
+ const len = Math.hypot(this.vel.x, this.vel.y) || 1;
+ const nx = this.vel.x / len;
+ const ny = this.vel.y / len;
+ const tailLen = 14;
+ const x1 = this.pos.x - nx * tailLen;
+ const y1 = this.pos.y - ny * tailLen;
+ ctx.save();
+ ctx.globalCompositeOperation = "lighter";
+ ctx.shadowColor = this.color;
+ ctx.shadowBlur = 14;
+ ctx.strokeStyle = this.color;
+ ctx.lineWidth = 3;
+ ctx.lineCap = "round";
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(this.pos.x, this.pos.y);
+ ctx.stroke();
+ ctx.restore();
+ }
+}
diff --git a/oh-my-claudecode/src/game/collision.js b/oh-my-claudecode/src/game/collision.js
new file mode 100644
index 0000000..b038311
--- /dev/null
+++ b/oh-my-claudecode/src/game/collision.js
@@ -0,0 +1,61 @@
+// Simple O(n^2) circle-vs-circle collision resolution for bullets/player/enemies.
+
+import { EnemyTypes, spawnSplitterChildren } from "./enemy.js";
+
+export function resolveCollisions(game) {
+ const bullets = game.bullets.items;
+ const enemies = game.enemies.items;
+ const player = game.player;
+
+ for (let i = 0; i < bullets.length; i++) {
+ const b = bullets[i];
+ if (!b.alive) continue;
+ for (let j = 0; j < enemies.length; j++) {
+ const e = enemies[j];
+ if (!e.alive) continue;
+ const dx = b.pos.x - e.pos.x;
+ const dy = b.pos.y - e.pos.y;
+ const r = b.radius + e.radius;
+ if (dx * dx + dy * dy <= r * r) {
+ const killed = e.takeDamage(b.damage);
+ b.alive = false;
+ game.particles.emit({
+ x: b.pos.x,
+ y: b.pos.y,
+ color: e.color,
+ count: 6,
+ speedMin: 60,
+ speedMax: 180,
+ lifetime: 0.25,
+ radius: 2.5,
+ });
+ if (killed) {
+ game.onEnemyKilled(e);
+ if (e.type === EnemyTypes.SPLITTER) {
+ for (const child of spawnSplitterChildren(e)) {
+ game.enemies.add(child);
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ if (player && player.alive && player.invuln <= 0) {
+ for (let j = 0; j < enemies.length; j++) {
+ const e = enemies[j];
+ if (!e.alive) continue;
+ const dx = player.pos.x - e.pos.x;
+ const dy = player.pos.y - e.pos.y;
+ const r = player.radius + e.radius;
+ if (dx * dx + dy * dy <= r * r) {
+ const took = player.takeDamage(1, e.pos.x, e.pos.y);
+ if (took) {
+ game.onPlayerHit(e);
+ }
+ break;
+ }
+ }
+ }
+}
diff --git a/oh-my-claudecode/src/game/enemy.js b/oh-my-claudecode/src/game/enemy.js
new file mode 100644
index 0000000..0a74c10
--- /dev/null
+++ b/oh-my-claudecode/src/game/enemy.js
@@ -0,0 +1,108 @@
+// Enemy types: chaser, bruiser, splitter. All seek the player.
+
+import { Entity } from "../engine/entity.js";
+
+export const EnemyTypes = {
+ CHASER: "chaser",
+ BRUISER: "bruiser",
+ SPLITTER: "splitter",
+ MINI: "mini",
+};
+
+const STATS = {
+ chaser: { speed: 110, hp: 1, radius: 12, color: "#f3f", score: 10 },
+ bruiser: { speed: 55, hp: 4, radius: 22, color: "#ff3", score: 40 },
+ splitter: { speed: 90, hp: 2, radius: 18, color: "#8f4", score: 25 },
+ mini: { speed: 140, hp: 1, radius: 8, color: "#f8c", score: 5 },
+};
+
+export class Enemy extends Entity {
+ constructor(opts = {}) {
+ const type = opts.type || EnemyTypes.CHASER;
+ const s = STATS[type] || STATS.chaser;
+ super({ radius: s.radius, ...opts });
+ this.type = type;
+ this.speed = s.speed;
+ this.maxHealth = s.hp;
+ this.health = s.hp;
+ this.color = s.color;
+ this.score = s.score;
+ this.hitFlash = 0;
+ this.wobble = Math.random() * Math.PI * 2;
+ }
+
+ update(dt, game) {
+ const target = game.player;
+ if (!target) return;
+ const dx = target.pos.x - this.pos.x;
+ const dy = target.pos.y - this.pos.y;
+ const len = Math.hypot(dx, dy) || 1;
+ this.vel.x = (dx / len) * this.speed;
+ this.vel.y = (dy / len) * this.speed;
+ this.pos.x += this.vel.x * dt;
+ this.pos.y += this.vel.y * dt;
+ this.wobble += dt * 4;
+ if (this.hitFlash > 0) this.hitFlash -= dt;
+ }
+
+ takeDamage(amount) {
+ this.health -= amount;
+ this.hitFlash = 0.08;
+ if (this.health <= 0) {
+ this.alive = false;
+ return true;
+ }
+ return false;
+ }
+
+ render(ctx) {
+ const wob = 1 + Math.sin(this.wobble) * 0.06;
+ const r = this.radius * wob;
+ ctx.save();
+ ctx.globalCompositeOperation = "lighter";
+ ctx.shadowColor = this.color;
+ ctx.shadowBlur = 18;
+ ctx.fillStyle = this.hitFlash > 0 ? "#fff" : this.color;
+ ctx.beginPath();
+ if (this.type === EnemyTypes.BRUISER) {
+ const sides = 6;
+ for (let i = 0; i < sides; i++) {
+ const a = (i / sides) * Math.PI * 2 + this.wobble * 0.3;
+ const x = this.pos.x + Math.cos(a) * r;
+ const y = this.pos.y + Math.sin(a) * r;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.closePath();
+ } else if (this.type === EnemyTypes.SPLITTER) {
+ const sides = 4;
+ for (let i = 0; i < sides; i++) {
+ const a = (i / sides) * Math.PI * 2 + this.wobble * 0.4;
+ const x = this.pos.x + Math.cos(a) * r;
+ const y = this.pos.y + Math.sin(a) * r;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.closePath();
+ } else {
+ ctx.arc(this.pos.x, this.pos.y, r, 0, Math.PI * 2);
+ }
+ ctx.fill();
+ ctx.restore();
+ }
+}
+
+export function spawnSplitterChildren(parent) {
+ const children = [];
+ for (let i = 0; i < 2; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const offset = parent.radius * 0.6;
+ const child = new Enemy({
+ type: EnemyTypes.MINI,
+ x: parent.pos.x + Math.cos(angle) * offset,
+ y: parent.pos.y + Math.sin(angle) * offset,
+ });
+ children.push(child);
+ }
+ return children;
+}
diff --git a/oh-my-claudecode/src/game/game.js b/oh-my-claudecode/src/game/game.js
new file mode 100644
index 0000000..0b5dc2c
--- /dev/null
+++ b/oh-my-claudecode/src/game/game.js
@@ -0,0 +1,264 @@
+// Game: ties player, enemies, bullets, waves, collision, particles, audio,
+// scoring/combo, screen shake, and the state machine together.
+
+import { EntityPool } from "../engine/entity.js";
+import { Player } from "./player.js";
+import { WaveManager } from "./waves.js";
+import { ParticleSystem } from "./particles.js";
+import { AudioBus } from "./audio.js";
+import { resolveCollisions } from "./collision.js";
+
+export const GameState = {
+ MENU: "menu",
+ PLAYING: "playing",
+ PAUSED: "paused",
+ GAMEOVER: "gameover",
+};
+
+const COMBO_WINDOW = 2.0;
+const SHAKE_DECAY = 10;
+
+export class Game {
+ constructor({ canvas, input, hud, highscore } = {}) {
+ this.canvas = canvas;
+ this.input = input;
+ this.hud = hud;
+ this.highscore = highscore;
+
+ this.state = GameState.MENU;
+ this.score = 0;
+ this.combo = 0;
+ this.comboTimer = 0;
+
+ this.player = new Player();
+ this.enemies = new EntityPool();
+ this.bullets = new EntityPool();
+ this.particles = new ParticleSystem();
+ this.waves = new WaveManager();
+ this.audio = new AudioBus();
+
+ this.shake = 0;
+ this.time = 0;
+
+ this.listeners = { stateChange: [] };
+ }
+
+ on(event, fn) {
+ if (!this.listeners[event]) this.listeners[event] = [];
+ this.listeners[event].push(fn);
+ }
+
+ _emit(event, payload) {
+ const list = this.listeners[event];
+ if (!list) return;
+ for (const fn of list) fn(payload);
+ }
+
+ _setState(next) {
+ if (this.state === next) return;
+ this.state = next;
+ this._emit("stateChange", next);
+ }
+
+ start() {
+ this.reset();
+ if (this.audio) this.audio.resume();
+ this._setState(GameState.PLAYING);
+ }
+
+ pause() {
+ if (this.state === GameState.PLAYING) this._setState(GameState.PAUSED);
+ }
+
+ resume() {
+ if (this.state === GameState.PAUSED) this._setState(GameState.PLAYING);
+ }
+
+ reset() {
+ this.score = 0;
+ this.combo = 0;
+ this.comboTimer = 0;
+ this.shake = 0;
+ this.time = 0;
+ this.player.reset();
+ this.enemies.clear();
+ this.bullets.clear();
+ this.particles.clear();
+ this.waves.reset();
+ this._syncHud();
+ }
+
+ _syncHud() {
+ if (!this.hud) return;
+ this.hud.setScore(this.score);
+ this.hud.setWave(this.waves.current);
+ this.hud.setHealth(this.player.health, this.player.maxHealth);
+ this.hud.setCombo(this._multiplier());
+ if (this.highscore) this.hud.setBest(this.highscore.get());
+ }
+
+ _multiplier() {
+ return 1 + Math.floor(this.combo / 5);
+ }
+
+ onEnemyKilled(enemy) {
+ const mult = this._multiplier();
+ const gained = enemy.score * mult;
+ this.score += gained;
+ this.combo += 1;
+ this.comboTimer = COMBO_WINDOW;
+ this.shake = Math.min(12, this.shake + 3);
+ this.particles.emit({
+ x: enemy.pos.x,
+ y: enemy.pos.y,
+ color: enemy.color,
+ count: 18,
+ speedMin: 80,
+ speedMax: 280,
+ lifetime: 0.55,
+ radius: 3.5,
+ });
+ if (this.audio) this.audio.play("explode");
+ if (this.hud) {
+ this.hud.setScore(this.score);
+ this.hud.setCombo(this._multiplier());
+ }
+ }
+
+ onPlayerHit(enemy) {
+ this.combo = 0;
+ this.comboTimer = 0;
+ this.shake = Math.min(22, this.shake + 10);
+ this.particles.emit({
+ x: this.player.pos.x,
+ y: this.player.pos.y,
+ color: "#f44",
+ count: 22,
+ speedMin: 100,
+ speedMax: 320,
+ lifetime: 0.6,
+ radius: 3.5,
+ });
+ if (this.audio) this.audio.play("hurt");
+ if (this.hud) {
+ this.hud.setHealth(this.player.health, this.player.maxHealth);
+ this.hud.setCombo(this._multiplier());
+ }
+ if (!this.player.alive || this.player.health <= 0) {
+ this._gameOver();
+ }
+ }
+
+ _gameOver() {
+ if (this.highscore) {
+ this.highscore.submit(this.score);
+ if (this.hud) this.hud.setBest(this.highscore.get());
+ }
+ if (this.hud && typeof this.hud.setFinalScore === "function") {
+ this.hud.setFinalScore(this.score);
+ }
+ if (this.audio) this.audio.play("gameover");
+ this._setState(GameState.GAMEOVER);
+ }
+
+ update(dt) {
+ if (this.state !== GameState.PLAYING) return;
+ this.time += dt;
+
+ if (this.comboTimer > 0) {
+ this.comboTimer -= dt;
+ if (this.comboTimer <= 0) {
+ this.combo = 0;
+ if (this.hud) this.hud.setCombo(this._multiplier());
+ }
+ }
+
+ if (this.shake > 0) {
+ this.shake *= Math.exp(-SHAKE_DECAY * dt);
+ if (this.shake < 0.05) this.shake = 0;
+ }
+
+ const prevWave = this.waves.current;
+
+ this.player.update(dt, this);
+
+ const bullets = this.bullets.items;
+ for (let i = 0; i < bullets.length; i++) bullets[i].update(dt, this);
+
+ const enemies = this.enemies.items;
+ for (let i = 0; i < enemies.length; i++) enemies[i].update(dt, this);
+
+ resolveCollisions(this);
+
+ this.waves.update(dt, this);
+ if (this.waves.current !== prevWave) {
+ if (this.audio) this.audio.play("wave");
+ this.shake = Math.min(18, this.shake + 6);
+ }
+
+ this.particles.update(dt);
+ this.bullets.filterAlive();
+ this.enemies.filterAlive();
+
+ if (!this.player.alive) {
+ this._gameOver();
+ }
+ }
+
+ _drawGrid(ctx) {
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+ const step = 64;
+ ctx.save();
+ ctx.strokeStyle = "rgba(80, 180, 255, 0.08)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ for (let x = 0; x <= w; x += step) {
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, h);
+ }
+ for (let y = 0; y <= h; y += step) {
+ ctx.moveTo(0, y);
+ ctx.lineTo(w, y);
+ }
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ render() {
+ const ctx = this.canvas?.ctx;
+ if (!ctx) return;
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+
+ ctx.save();
+ ctx.fillStyle = "#070712";
+ ctx.fillRect(0, 0, w, h);
+
+ let ox = 0;
+ let oy = 0;
+ if (this.shake > 0.1) {
+ ox = (Math.random() - 0.5) * this.shake;
+ oy = (Math.random() - 0.5) * this.shake;
+ ctx.translate(ox, oy);
+ }
+
+ this._drawGrid(ctx);
+
+ if (this.state !== GameState.MENU) {
+ const bullets = this.bullets.items;
+ for (let i = 0; i < bullets.length; i++) bullets[i].render(ctx);
+
+ const enemies = this.enemies.items;
+ for (let i = 0; i < enemies.length; i++) enemies[i].render(ctx);
+
+ if (this.player.alive || this.state === GameState.GAMEOVER) {
+ this.player.render(ctx);
+ }
+
+ this.particles.render(ctx);
+ }
+
+ ctx.restore();
+ }
+}
diff --git a/oh-my-claudecode/src/game/particles.js b/oh-my-claudecode/src/game/particles.js
new file mode 100644
index 0000000..e922082
--- /dev/null
+++ b/oh-my-claudecode/src/game/particles.js
@@ -0,0 +1,63 @@
+// Particle system: additive neon bursts with shrinking radius.
+
+export class ParticleSystem {
+ constructor() {
+ this.particles = [];
+ }
+
+ emit({ x, y, color = "#fff", count = 10, speedMin = 60, speedMax = 240, lifetime = 0.6, radius = 3 } = {}) {
+ for (let i = 0; i < count; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const speed = speedMin + Math.random() * (speedMax - speedMin);
+ this.particles.push({
+ x,
+ y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ life: lifetime,
+ maxLife: lifetime,
+ color,
+ radius,
+ });
+ }
+ }
+
+ update(dt) {
+ const drag = Math.exp(-2.5 * dt);
+ let write = 0;
+ for (let i = 0; i < this.particles.length; i++) {
+ const p = this.particles[i];
+ p.life -= dt;
+ if (p.life <= 0) continue;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt;
+ p.vx *= drag;
+ p.vy *= drag;
+ this.particles[write++] = p;
+ }
+ this.particles.length = write;
+ }
+
+ render(ctx) {
+ if (this.particles.length === 0) return;
+ ctx.save();
+ ctx.globalCompositeOperation = "lighter";
+ for (const p of this.particles) {
+ const t = p.life / p.maxLife;
+ const r = p.radius * t;
+ if (r <= 0) continue;
+ ctx.globalAlpha = Math.max(0, Math.min(1, t));
+ ctx.shadowColor = p.color;
+ ctx.shadowBlur = 12;
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ clear() {
+ this.particles.length = 0;
+ }
+}
diff --git a/oh-my-claudecode/src/game/player.js b/oh-my-claudecode/src/game/player.js
new file mode 100644
index 0000000..3509612
--- /dev/null
+++ b/oh-my-claudecode/src/game/player.js
@@ -0,0 +1,152 @@
+// Player: WASD/arrow movement, mouse aim, click/space to shoot, invuln after hit.
+
+import { Entity } from "../engine/entity.js";
+import { Bullet } from "./bullet.js";
+
+const SPEED = 260;
+const SHOOT_COOLDOWN = 0.14;
+const INVULN_TIME = 0.9;
+const KNOCKBACK_DECAY = 6;
+
+export class Player extends Entity {
+ constructor(opts = {}) {
+ super({ x: 640, y: 360, radius: 14, ...opts });
+ this.maxHealth = 3;
+ this.health = 3;
+ this.invuln = 0;
+ this.cooldown = 0;
+ this.knockback = { x: 0, y: 0 };
+ this.aim = { x: 1, y: 0 };
+ }
+
+ reset() {
+ this.health = this.maxHealth;
+ this.pos.x = 640;
+ this.pos.y = 360;
+ this.vel.x = 0;
+ this.vel.y = 0;
+ this.invuln = 0;
+ this.cooldown = 0;
+ this.knockback.x = 0;
+ this.knockback.y = 0;
+ this.alive = true;
+ }
+
+ update(dt, game) {
+ const input = game.input;
+ const canvas = game.canvas;
+ if (!input || !canvas) return;
+
+ let mx = 0;
+ let my = 0;
+ if (input.anyDown(["KeyW", "ArrowUp"])) my -= 1;
+ if (input.anyDown(["KeyS", "ArrowDown"])) my += 1;
+ if (input.anyDown(["KeyA", "ArrowLeft"])) mx -= 1;
+ if (input.anyDown(["KeyD", "ArrowRight"])) mx += 1;
+
+ const len = Math.hypot(mx, my);
+ if (len > 0) {
+ mx /= len;
+ my /= len;
+ }
+ this.vel.x = mx * SPEED;
+ this.vel.y = my * SPEED;
+
+ this.pos.x += (this.vel.x + this.knockback.x) * dt;
+ this.pos.y += (this.vel.y + this.knockback.y) * dt;
+
+ const decay = Math.exp(-KNOCKBACK_DECAY * dt);
+ this.knockback.x *= decay;
+ this.knockback.y *= decay;
+ if (Math.abs(this.knockback.x) < 1) this.knockback.x = 0;
+ if (Math.abs(this.knockback.y) < 1) this.knockback.y = 0;
+
+ const r = this.radius;
+ if (this.pos.x < r) this.pos.x = r;
+ if (this.pos.y < r) this.pos.y = r;
+ if (this.pos.x > canvas.width - r) this.pos.x = canvas.width - r;
+ if (this.pos.y > canvas.height - r) this.pos.y = canvas.height - r;
+
+ const mouse = input.mouse;
+ const dx = mouse.x - this.pos.x;
+ const dy = mouse.y - this.pos.y;
+ const aimLen = Math.hypot(dx, dy);
+ if (aimLen > 0.001) {
+ this.aim.x = dx / aimLen;
+ this.aim.y = dy / aimLen;
+ }
+
+ if (this.cooldown > 0) this.cooldown -= dt;
+ if (this.invuln > 0) this.invuln -= dt;
+
+ const wantsShoot = mouse.down || input.isDown("Space");
+ if (wantsShoot && this.cooldown <= 0) {
+ this.shoot(game);
+ this.cooldown = SHOOT_COOLDOWN;
+ }
+ }
+
+ shoot(game) {
+ const speed = 520;
+ const vx = this.aim.x * speed;
+ const vy = this.aim.y * speed;
+ const spawnX = this.pos.x + this.aim.x * (this.radius + 2);
+ const spawnY = this.pos.y + this.aim.y * (this.radius + 2);
+ const bullet = new Bullet({ x: spawnX, y: spawnY, vx, vy, owner: "player" });
+ game.bullets.add(bullet);
+ game.particles.emit({
+ x: spawnX,
+ y: spawnY,
+ color: "#7ff",
+ count: 4,
+ speedMin: 40,
+ speedMax: 140,
+ lifetime: 0.18,
+ radius: 2.5,
+ });
+ if (game.audio) game.audio.play("shoot");
+ }
+
+ takeDamage(amount, fromX, fromY) {
+ if (this.invuln > 0) return false;
+ this.health -= amount;
+ this.invuln = INVULN_TIME;
+ if (typeof fromX === "number" && typeof fromY === "number") {
+ const dx = this.pos.x - fromX;
+ const dy = this.pos.y - fromY;
+ const len = Math.hypot(dx, dy) || 1;
+ const force = 320;
+ this.knockback.x = (dx / len) * force;
+ this.knockback.y = (dy / len) * force;
+ }
+ if (this.health <= 0) {
+ this.health = 0;
+ this.alive = false;
+ }
+ return true;
+ }
+
+ render(ctx) {
+ const flicker = this.invuln > 0 && Math.floor(this.invuln * 24) % 2 === 0;
+ ctx.save();
+ ctx.globalCompositeOperation = "lighter";
+ ctx.shadowColor = "#0ff";
+ ctx.shadowBlur = 22;
+ ctx.fillStyle = flicker ? "#6ff" : "#0ff";
+ ctx.beginPath();
+ ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = "#fff";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(this.pos.x, this.pos.y);
+ ctx.lineTo(
+ this.pos.x + this.aim.x * (this.radius + 10),
+ this.pos.y + this.aim.y * (this.radius + 10),
+ );
+ ctx.stroke();
+ ctx.restore();
+ }
+}
diff --git a/oh-my-claudecode/src/game/waves.js b/oh-my-claudecode/src/game/waves.js
new file mode 100644
index 0000000..1684311
--- /dev/null
+++ b/oh-my-claudecode/src/game/waves.js
@@ -0,0 +1,107 @@
+// Wave spawner: schedules enemies with stagger, advances wave when field is clear.
+
+import { Enemy, EnemyTypes } from "./enemy.js";
+
+const INTERMISSION = 2.0;
+const SPAWN_STAGGER = 0.35;
+
+export class WaveManager {
+ constructor() {
+ this.wave = 1;
+ this.queue = [];
+ this.spawnTimer = 0;
+ this.intermission = 0;
+ this.waveActive = false;
+ this._buildQueue(this.wave);
+ }
+
+ reset() {
+ this.wave = 1;
+ this.queue.length = 0;
+ this.spawnTimer = 0;
+ this.intermission = 0;
+ this.waveActive = false;
+ this._buildQueue(this.wave);
+ this.waveActive = true;
+ }
+
+ get current() {
+ return this.wave;
+ }
+
+ _buildQueue(wave) {
+ const q = [];
+ if (wave === 1) {
+ for (let i = 0; i < 8; i++) q.push(EnemyTypes.CHASER);
+ } else if (wave === 2) {
+ for (let i = 0; i < 10; i++) q.push(EnemyTypes.CHASER);
+ for (let i = 0; i < 2; i++) q.push(EnemyTypes.BRUISER);
+ } else {
+ const chasers = 8 + wave * 2;
+ const bruisers = Math.floor(wave / 2);
+ const splitters = Math.max(0, wave - 2);
+ for (let i = 0; i < chasers; i++) q.push(EnemyTypes.CHASER);
+ for (let i = 0; i < bruisers; i++) q.push(EnemyTypes.BRUISER);
+ for (let i = 0; i < splitters; i++) q.push(EnemyTypes.SPLITTER);
+ }
+ for (let i = q.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [q[i], q[j]] = [q[j], q[i]];
+ }
+ this.queue = q;
+ this.spawnTimer = 0.4;
+ }
+
+ _spawnOffscreen(type, canvas) {
+ const w = canvas.width;
+ const h = canvas.height;
+ const edge = Math.floor(Math.random() * 4);
+ let x = 0;
+ let y = 0;
+ const pad = 40;
+ if (edge === 0) {
+ x = Math.random() * w;
+ y = -pad;
+ } else if (edge === 1) {
+ x = w + pad;
+ y = Math.random() * h;
+ } else if (edge === 2) {
+ x = Math.random() * w;
+ y = h + pad;
+ } else {
+ x = -pad;
+ y = Math.random() * h;
+ }
+ return new Enemy({ type, x, y });
+ }
+
+ update(dt, game) {
+ if (this.intermission > 0) {
+ this.intermission -= dt;
+ if (this.intermission <= 0) {
+ this.wave += 1;
+ this._buildQueue(this.wave);
+ this.waveActive = true;
+ if (game.hud) game.hud.setWave(this.wave);
+ }
+ return;
+ }
+
+ if (!this.waveActive) {
+ this.waveActive = true;
+ }
+
+ if (this.queue.length > 0) {
+ this.spawnTimer -= dt;
+ if (this.spawnTimer <= 0) {
+ const type = this.queue.shift();
+ const enemy = this._spawnOffscreen(type, game.canvas);
+ game.enemies.add(enemy);
+ this.spawnTimer = SPAWN_STAGGER;
+ }
+ } else if (game.enemies.size === 0) {
+ this.waveActive = false;
+ this.intermission = INTERMISSION;
+ }
+ }
+}
diff --git a/oh-my-claudecode/src/main.js b/oh-my-claudecode/src/main.js
new file mode 100644
index 0000000..5adfeae
--- /dev/null
+++ b/oh-my-claudecode/src/main.js
@@ -0,0 +1,69 @@
+// Boots engine + game + UI and wires overlays, input, and the main loop.
+
+import { createCanvas } from "./engine/canvas.js";
+import { createInput } from "./engine/input.js";
+import { startLoop } from "./engine/loop.js";
+import { Game, GameState } from "./game/game.js";
+import { HUD } from "./ui/hud.js";
+import { Menu } from "./ui/menu.js";
+import { HighScore } from "./ui/highscore.js";
+
+const canvasEl = document.getElementById("game");
+if (!canvasEl) throw new Error("#game canvas not found");
+
+const canvas = createCanvas(canvasEl);
+const input = createInput({ canvas: canvasEl, logicalWidth: canvas.width, logicalHeight: canvas.height });
+const hud = new HUD(document);
+const highscore = new HighScore();
+
+const game = new Game({ canvas, input, hud, highscore });
+hud.setBest(highscore.get());
+
+const menu = new Menu(document, {
+ start: () => {
+ game.start();
+ },
+ resume: () => {
+ game.resume();
+ },
+ reset: () => {
+ game.start();
+ },
+});
+
+function renderOverlay(state) {
+ if (state === GameState.MENU) menu.show("menu");
+ else if (state === GameState.PAUSED) menu.show("pause");
+ else if (state === GameState.GAMEOVER) menu.show("gameover");
+ else menu.hideAll();
+}
+
+game.on("stateChange", renderOverlay);
+renderOverlay(game.state);
+
+const loop = startLoop({
+ update: (dt) => {
+ if (input.wasPressed("KeyP") || input.wasPressed("Escape")) {
+ if (game.state === GameState.PLAYING) game.pause();
+ else if (game.state === GameState.PAUSED) game.resume();
+ }
+ game.update(dt);
+ input.endFrame();
+ },
+ render: (alpha, frameDt) => {
+ game.render(alpha, frameDt);
+ },
+});
+
+// Expose a small debug handle for other workers + console tinkering.
+window.__omc = { game, loop, canvas, input, hud, highscore };
+
+let lastFpsLog = 0;
+setInterval(() => {
+ const now = performance.now();
+ if (now - lastFpsLog > 4500) {
+ lastFpsLog = now;
+ // eslint-disable-next-line no-console
+ console.log(`[omc] fps=${loop.fps} state=${game.state}`);
+ }
+}, 5000);
diff --git a/oh-my-claudecode/src/ui/highscore.js b/oh-my-claudecode/src/ui/highscore.js
new file mode 100644
index 0000000..1ae9d1f
--- /dev/null
+++ b/oh-my-claudecode/src/ui/highscore.js
@@ -0,0 +1,34 @@
+// localStorage-backed high score. Safe-guarded so the game still runs if
+// storage is blocked (private mode / disabled cookies).
+
+const KEY = "omc-neon-arena.best";
+
+export class HighScore {
+ constructor() {
+ this.value = 0;
+ try {
+ const raw = window.localStorage.getItem(KEY);
+ const parsed = raw == null ? 0 : parseInt(raw, 10);
+ this.value = Number.isFinite(parsed) ? parsed : 0;
+ } catch {
+ this.value = 0;
+ }
+ }
+
+ get() {
+ return this.value;
+ }
+
+ submit(score) {
+ if (score > this.value) {
+ this.value = score;
+ try {
+ window.localStorage.setItem(KEY, String(score));
+ } catch {
+ /* ignore storage errors */
+ }
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/oh-my-claudecode/src/ui/hud.js b/oh-my-claudecode/src/ui/hud.js
new file mode 100644
index 0000000..3d19b61
--- /dev/null
+++ b/oh-my-claudecode/src/ui/hud.js
@@ -0,0 +1,89 @@
+// HUD — renders score, wave, health pips, and combo into the DOM.
+
+const PIP_COUNT = 10;
+
+export class HUD {
+ constructor(root = document) {
+ this.root = root;
+ this.nodes = {
+ score: root.querySelector('[data-hud="score"]'),
+ wave: root.querySelector('[data-hud="wave"]'),
+ health: root.querySelector('[data-hud="health"]'),
+ combo: root.querySelector('[data-hud="combo"]'),
+ best: root.querySelector('[data-hud="best"]'),
+ finalScore: root.querySelector('[data-hud="final-score"]'),
+ finalBest: root.querySelector('[data-hud="final-best"]'),
+ };
+
+ this._maxHealth = 100;
+ this._buildPips();
+ }
+
+ _buildPips() {
+ const healthSlot = this.root.querySelector('[data-slot="health"]');
+ if (!healthSlot) return;
+
+ // Replace bare text with label + pip strip
+ healthSlot.innerHTML = 'HP';
+
+ const strip = document.createElement("div");
+ strip.className = "health-pips";
+ strip.setAttribute("aria-label", "health");
+
+ this._pips = [];
+ for (let i = 0; i < PIP_COUNT; i++) {
+ const pip = document.createElement("span");
+ pip.className = "health-pip";
+ strip.appendChild(pip);
+ this._pips.push(pip);
+ }
+ healthSlot.appendChild(strip);
+
+ // Append the numeric span back for screen-readers / game logic reads
+ const numeric = document.createElement("span");
+ numeric.setAttribute("data-hud", "health");
+ numeric.style.display = "none";
+ healthSlot.appendChild(numeric);
+
+ // Update our node reference to the hidden numeric span
+ this.nodes.health = numeric;
+ }
+
+ setScore(v) {
+ if (this.nodes.score) this.nodes.score.textContent = String(v);
+ }
+
+ setWave(v) {
+ if (this.nodes.wave) this.nodes.wave.textContent = String(v);
+ }
+
+ setHealth(v, max) {
+ if (max != null) this._maxHealth = max;
+ const hp = Math.max(0, Math.round(v));
+ if (this.nodes.health) this.nodes.health.textContent = String(hp);
+
+ if (!this._pips) return;
+ const ratio = this._maxHealth > 0 ? hp / this._maxHealth : 0;
+ const filled = Math.round(ratio * PIP_COUNT);
+ const isDanger = ratio <= 0.3;
+
+ this._pips.forEach((pip, i) => {
+ const active = i < filled;
+ pip.classList.toggle("empty", !active);
+ pip.classList.toggle("danger", active && isDanger);
+ });
+ }
+
+ setCombo(v) {
+ if (this.nodes.combo) this.nodes.combo.textContent = `x${v}`;
+ }
+
+ setBest(v) {
+ if (this.nodes.best) this.nodes.best.textContent = String(v);
+ if (this.nodes.finalBest) this.nodes.finalBest.textContent = String(v);
+ }
+
+ setFinalScore(v) {
+ if (this.nodes.finalScore) this.nodes.finalScore.textContent = String(v);
+ }
+}
diff --git a/oh-my-claudecode/src/ui/menu.js b/oh-my-claudecode/src/ui/menu.js
new file mode 100644
index 0000000..e5eed5a
--- /dev/null
+++ b/oh-my-claudecode/src/ui/menu.js
@@ -0,0 +1,39 @@
+// Overlay manager: shows/hides the three overlay panels (menu/pause/gameover)
+// and wires their buttons to callbacks. Designer (worker-3) may restyle, but
+// the JS interface here stays stable.
+
+const OVERLAYS = ["menu", "pause", "gameover"];
+
+export class Menu {
+ constructor(root = document, handlers = {}) {
+ this.root = root;
+ this.handlers = handlers;
+ this.elements = {};
+ for (const name of OVERLAYS) {
+ this.elements[name] = root.querySelector(`[data-overlay="${name}"]`);
+ }
+
+ root.addEventListener("click", (e) => {
+ const target = e.target.closest("[data-action]");
+ if (!target) return;
+ const action = target.getAttribute("data-action");
+ const handler = this.handlers[action];
+ if (typeof handler === "function") handler();
+ });
+ }
+
+ show(name) {
+ for (const key of OVERLAYS) {
+ const el = this.elements[key];
+ if (!el) continue;
+ el.classList.toggle("hidden", key !== name);
+ }
+ }
+
+ hideAll() {
+ for (const key of OVERLAYS) {
+ const el = this.elements[key];
+ if (el) el.classList.add("hidden");
+ }
+ }
+}
diff --git a/oh-my-claudecode/styles/main.css b/oh-my-claudecode/styles/main.css
new file mode 100644
index 0000000..54b2795
--- /dev/null
+++ b/oh-my-claudecode/styles/main.css
@@ -0,0 +1,378 @@
+/* OMC Neon Arena — full neon arcade theme (worker-3) */
+
+/* ── Palette tokens ───────────────────────────────────────────────────────── */
+:root {
+ --bg: #05060a;
+ --grid: #101828;
+ --cyan: #22e4ff;
+ --magenta: #ff3df0;
+ --yellow: #ffd84d;
+ --lime: #7dff6d;
+ --text: #e7f0ff;
+ --dim: rgba(231, 240, 255, 0.55);
+ --panel-bg: rgba(5, 8, 18, 0.92);
+ --glow-c: 0 0 8px var(--cyan), 0 0 24px rgba(34, 228, 255, 0.4);
+ --glow-m: 0 0 8px var(--magenta), 0 0 24px rgba(255, 61, 240, 0.4);
+}
+
+/* ── Reset ───────────────────────────────────────────────────────────────── */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+/* ── Base ────────────────────────────────────────────────────────────────── */
+html,
+body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: "Courier New", Courier, monospace;
+ overflow: hidden;
+ user-select: none;
+}
+
+/* ── App shell ───────────────────────────────────────────────────────────── */
+#app {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: grid;
+ place-items: center;
+ /* subtle radial vignette */
+ background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.7) 100%);
+}
+
+/* ── Canvas ──────────────────────────────────────────────────────────────── */
+#game {
+ display: block;
+ width: min(100vw, calc(100vh * 16 / 9));
+ height: min(100vh, calc(100vw * 9 / 16));
+ max-width: 1280px;
+ max-height: 720px;
+ background: var(--bg);
+ image-rendering: pixelated;
+ cursor: crosshair;
+ box-shadow:
+ 0 0 0 1px rgba(34, 228, 255, 0.18),
+ 0 0 32px rgba(34, 228, 255, 0.12),
+ 0 0 80px rgba(34, 228, 255, 0.06);
+}
+
+/* ── HUD ─────────────────────────────────────────────────────────────────── */
+.hud {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ grid-template-rows: auto 1fr auto;
+ padding: 0.75rem 1rem;
+ gap: 0.4rem;
+}
+
+/* score + combo: top-left */
+.hud__slot[data-slot="score"] {
+ grid-column: 1;
+ grid-row: 1;
+ justify-self: start;
+}
+.hud__slot[data-slot="combo"] {
+ grid-column: 1;
+ grid-row: 2;
+ align-self: start;
+ margin-top: 0.15rem;
+}
+
+/* wave: top-center */
+.hud__slot[data-slot="wave"] {
+ grid-column: 2;
+ grid-row: 1;
+ justify-self: center;
+}
+
+/* health: bottom-left */
+.hud__slot[data-slot="health"] {
+ grid-column: 1;
+ grid-row: 3;
+ justify-self: start;
+ align-self: end;
+}
+
+.hud__slot {
+ background: rgba(0, 0, 0, 0.45);
+ border: 1px solid rgba(34, 228, 255, 0.18);
+ padding: 0.2rem 0.55rem;
+ border-radius: 3px;
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--dim);
+ font-variant-numeric: tabular-nums;
+ backdrop-filter: blur(4px);
+}
+
+.hud__slot [data-hud] {
+ color: var(--cyan);
+ text-shadow: 0 0 6px var(--cyan);
+ font-weight: bold;
+}
+
+/* combo highlight when active */
+.hud__slot[data-slot="combo"] [data-hud="combo"] {
+ color: var(--yellow);
+ text-shadow: 0 0 6px var(--yellow);
+}
+
+/* wave number stands out in magenta */
+.hud__slot[data-slot="wave"] [data-hud="wave"] {
+ color: var(--magenta);
+ text-shadow: 0 0 6px var(--magenta);
+}
+
+/* ── Health pips ─────────────────────────────────────────────────────────── */
+.hud__slot[data-slot="health"] {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.hud__slot[data-slot="health"] .hud__label {
+ color: var(--dim);
+}
+
+.health-pips {
+ display: flex;
+ gap: 3px;
+ align-items: center;
+}
+
+.health-pip {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ background: var(--lime);
+ box-shadow: 0 0 5px var(--lime), 0 0 10px rgba(125, 255, 109, 0.35);
+ transition: background 0.15s, box-shadow 0.15s, opacity 0.15s;
+}
+
+.health-pip.empty {
+ background: rgba(125, 255, 109, 0.12);
+ box-shadow: none;
+ opacity: 0.35;
+}
+
+.health-pip.danger {
+ background: var(--magenta);
+ box-shadow: 0 0 5px var(--magenta), 0 0 10px rgba(255, 61, 240, 0.4);
+ animation: pip-pulse 0.6s ease-in-out infinite alternate;
+}
+
+@keyframes pip-pulse {
+ from { opacity: 1; }
+ to { opacity: 0.45; }
+}
+
+/* ── Overlay base ────────────────────────────────────────────────────────── */
+.overlay {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ z-index: 10;
+ backdrop-filter: blur(3px);
+ background: rgba(0, 0, 0, 0.62);
+}
+
+.overlay.hidden {
+ display: none;
+}
+
+.overlay__panel {
+ background: var(--panel-bg);
+ border: 1px solid rgba(34, 228, 255, 0.25);
+ border-radius: 6px;
+ padding: 2rem 2.5rem;
+ text-align: center;
+ max-width: 30rem;
+ width: 90%;
+ box-shadow:
+ 0 0 0 1px rgba(34, 228, 255, 0.08),
+ 0 0 40px rgba(34, 228, 255, 0.08),
+ inset 0 1px 0 rgba(34, 228, 255, 0.12);
+ position: relative;
+ overflow: hidden;
+}
+
+/* subtle scan-line shimmer on panel */
+.overlay__panel::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 180deg,
+ transparent 0px,
+ transparent 3px,
+ rgba(34, 228, 255, 0.025) 3px,
+ rgba(34, 228, 255, 0.025) 4px
+ );
+ pointer-events: none;
+ border-radius: inherit;
+}
+
+/* ── Overlay title ───────────────────────────────────────────────────────── */
+.overlay__title {
+ margin: 0 0 0.6rem;
+ font-size: clamp(1.6rem, 5vw, 2.4rem);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--cyan);
+ text-shadow: var(--glow-c);
+}
+
+/* animated glow pulse on the main menu title only */
+.overlay--menu .overlay__title {
+ animation: title-glow 2.4s ease-in-out infinite alternate;
+}
+
+@keyframes title-glow {
+ from {
+ text-shadow: 0 0 6px var(--cyan), 0 0 18px rgba(34, 228, 255, 0.4);
+ color: var(--cyan);
+ }
+ to {
+ text-shadow:
+ 0 0 12px var(--cyan),
+ 0 0 40px rgba(34, 228, 255, 0.7),
+ 0 0 80px rgba(34, 228, 255, 0.3);
+ color: #a8f7ff;
+ }
+}
+
+/* pause title in magenta */
+.overlay--pause .overlay__title {
+ color: var(--magenta);
+ text-shadow: var(--glow-m);
+}
+
+/* gameover title in yellow */
+.overlay--gameover .overlay__title {
+ color: var(--yellow);
+ text-shadow: 0 0 8px var(--yellow), 0 0 24px rgba(255, 216, 77, 0.45);
+}
+
+/* ── Overlay text ────────────────────────────────────────────────────────── */
+.overlay__subtitle {
+ margin: 0 0 1.2rem;
+ color: var(--dim);
+ font-size: 0.8rem;
+ letter-spacing: 0.04em;
+ line-height: 1.6;
+}
+
+.overlay__meta {
+ margin: 0.45rem 0;
+ color: var(--dim);
+ font-size: 0.85rem;
+ letter-spacing: 0.03em;
+}
+
+.overlay__meta [data-hud] {
+ color: var(--cyan);
+ text-shadow: 0 0 5px var(--cyan);
+}
+
+/* ── Buttons ─────────────────────────────────────────────────────────────── */
+.overlay__button {
+ display: inline-block;
+ margin: 0.55rem 0.3rem 0;
+ padding: 0.55rem 1.4rem;
+ border: 1px solid var(--cyan);
+ background: transparent;
+ color: var(--cyan);
+ font: inherit;
+ font-size: 0.85rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ cursor: pointer;
+ border-radius: 3px;
+ text-shadow: 0 0 6px var(--cyan);
+ box-shadow: 0 0 8px rgba(34, 228, 255, 0.2), inset 0 0 8px rgba(34, 228, 255, 0.05);
+ transition: background 0.18s, box-shadow 0.18s, transform 0.1s, color 0.18s;
+ position: relative;
+ overflow: hidden;
+}
+
+.overlay__button::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(135deg, rgba(34, 228, 255, 0.1) 0%, transparent 60%);
+ opacity: 0;
+ transition: opacity 0.18s;
+}
+
+.overlay__button:hover {
+ background: rgba(34, 228, 255, 0.1);
+ box-shadow: 0 0 16px rgba(34, 228, 255, 0.5), inset 0 0 12px rgba(34, 228, 255, 0.1);
+ transform: translateY(-1px) scale(1.03);
+ color: #fff;
+ text-shadow: 0 0 10px var(--cyan);
+}
+
+.overlay__button:hover::before {
+ opacity: 1;
+}
+
+.overlay__button:active {
+ transform: translateY(0) scale(0.97);
+ box-shadow: 0 0 8px rgba(34, 228, 255, 0.3);
+}
+
+/* restart / try-again button gets magenta treatment */
+.overlay__button[data-action="reset"] {
+ border-color: var(--magenta);
+ color: var(--magenta);
+ text-shadow: 0 0 6px var(--magenta);
+ box-shadow: 0 0 8px rgba(255, 61, 240, 0.2), inset 0 0 8px rgba(255, 61, 240, 0.05);
+}
+
+.overlay__button[data-action="reset"]:hover {
+ background: rgba(255, 61, 240, 0.1);
+ box-shadow: 0 0 16px rgba(255, 61, 240, 0.5), inset 0 0 12px rgba(255, 61, 240, 0.1);
+ color: #fff;
+ text-shadow: 0 0 10px var(--magenta);
+}
+
+/* ── Responsive ──────────────────────────────────────────────────────────── */
+@media (max-width: 640px) {
+ .overlay__panel {
+ padding: 1.4rem 1.2rem;
+ }
+
+ .overlay__title {
+ font-size: 1.4rem;
+ }
+
+ .hud__slot {
+ font-size: 0.65rem;
+ padding: 0.15rem 0.4rem;
+ }
+
+ .health-pip {
+ width: 8px;
+ height: 8px;
+ }
+}
+
+@media (max-width: 400px) {
+ .overlay__button {
+ display: block;
+ width: 100%;
+ margin: 0.4rem 0 0;
+ }
+}