A round-based 3D zombie-survival FPS that runs entirely in the browser — with zero binary assets. No models, no textures, no audio files. Every polygon is authored in code, every texture is drawn to a <canvas> at boot, and every sound is synthesised from oscillators and noise.
▶ Play it in your browser — no install, no download, ~200 KB gzipped.
Hold a facility called Blackline Facility against escalating rounds of the dead. Earn Scrip by killing them, spend it on doors, wall guns, ammo, buff stations, the Cache Roulette weapon box, and the Anvil Station upgrade rig. Round 13 is where zombie speed reaches 4.4 m/s — faster than you can walk. Most runs end within a few rounds of it.
Original work inspired by the genre as a whole. No third-party publisher, studio, map, character or weapon names appear anywhere in this repository; that is enforced by a test, not a promise (see IP hygiene).
- Architecture
- Quick start
- Controls
- Balance
- Module map
- Tuning guide
- Accessibility
- Zero assets
- Testing
- Deployment
- Browser requirements
- Known limitations
- License
A fixed-timestep simulation core drives nine systems that communicate exclusively through a typed event bus and a service registry. The renderer sits at the end of the chain, not in the middle of it — which is what lets the entire game run headlessly in Node with Three.js absent from the module graph.
The inline SVG this diagram was originally drawn as is stripped by GitHub's Markdown sanitiser, so Mermaid is the only form that actually renders here.
flowchart TD
M["src/main.ts → src/game/bootstrap.ts"] --> L["src/core/loop.ts<br/>fixed 60 Hz accumulator"]
L --> B["src/core/bus.ts<br/>EventBus<GameEventMap>"]
L --> R["src/core/registry.ts<br/>ServiceRegistry"]
B --> S
R --> S
subgraph S["nine systems"]
LV[level] --- PL[player] --- WP[weapons]
AI[ai] --- DR[director] --- EC[economy]
AU[audio] --- UI[ui] --- EN[engine]
end
EN --> RN["src/engine/renderer.ts<br/>three.js 0.169 · WebGL2"]
RN --> CV["canvas#game"]
T["tests/sim · tests/unit"] --> H["src/game/headless.ts<br/>createHeadlessGame()"]
H -. "no WebGL · no WebAudio · no DOM" .-> S
H -. "engine · audio · ui bypassed" .-x RN
Why the shape matters. Systems never import each other. They read the frozen interfaces in src/types/**, resolve peers through the ServiceRegistry, and communicate through a single typed GameEventMap. That is what makes createHeadlessGame() possible: the same gameplay code that runs in a browser runs in Node with no DOM, no WebGL and no WebAudio, which is how 15 full 30-round games are simulated in the balance suite.
git clone https://github.com/justinw916-sketch/claude-zombies.git
cd claude-zombies
npm ci
npm run dev # http://localhost:5173Production build and local preview:
npm run build # tsc --noEmit && vite build → dist/
npm run preview # http://localhost:4173Everything at once (the full gate ladder):
npm run verify # typecheck → lint → unit → sim → buildNode 20 or newer (engines.node: ">=20"). CI runs Node 20; local development is on Node 24.
Every action below is remappable in Settings → Controls. Values are KeyboardEvent.code tokens, plus the synthetic tokens Mouse0–Mouse4. Defaults live in src/ui/settings.ts (DEFAULT_KEY_BINDINGS).
| Action | Default | Alternate |
|---|---|---|
| Move Forward | W |
↑ |
| Move Back | S |
↓ |
| Move Left | A |
← |
| Move Right | D |
→ |
| Jump | Space |
— |
| Crouch | Left Ctrl |
C |
| Sprint | Left Shift |
— |
| Fire | Mouse 0 (left) |
— |
| Aim (ADS) | Mouse 1 (right) |
— |
| Reload | R |
— |
| Interact / repair barrier | F |
— |
| Melee | V |
— |
| Frag Grenade | G |
— |
| Pulse Grenade | H |
— |
| Weapon Slot 1 | 1 |
— |
| Weapon Slot 2 | 2 |
— |
| Swap Weapon | Q |
— |
| Pause | Esc |
— |
| Dev Console | ` (backquote) |
— |
| Toggle FPS overlay | F3 |
— |
| Scoreboard | Tab |
— |
Movement: walk 4.2 m/s, sprint 6.6 m/s for 5 s with a 3 s recovery, crouch 2.1 m/s. Health 100 base, regen begins 4 s after the last hit at 25 hp/s. Downed is a 12 s bleed-out — Second Wind revives you automatically once, then is consumed.
Interact is edge-triggered (one purchase per press), but held interact repairs the nearest damaged barrier within 2.5 m. An intact barrier is not a repair candidate, so a held key cannot farm Scrip.
Frozen math from src/systems/director/curves.ts — not tunable, exported as standalone pure functions so tests can import them without constructing the director. Health is displayed rounded; the curve itself is health(r-1) × 1.10 anchored at health(9) = 950. Maximum concurrent zombies is 24 at all times.
| Round | Zombies | Health | Speed m/s | Spawn interval s | Ether Hounds | Rendmen |
|---|---|---|---|---|---|---|
| 1 | 6 | 150 | 1.2 | 1.94 | — | — |
| 2 | 8 | 250 | 1.2 | 1.88 | — | — |
| 3 | 10 | 350 | 1.2 | 1.82 | — | — |
| 4 | 12 | 450 | 1.2 | 1.76 | — | — |
| 5 | 14 | 550 | 2.2 | 1.70 | — | — |
| 6 | 16 | 650 | 2.2 | 1.64 | 9 | — |
| 7 | 18 | 750 | 2.2 | 1.58 | — | — |
| 8 | 20 | 850 | 2.2 | 1.52 | — | — |
| 9 | 22 | 950 | 3.4 | 1.46 | — | — |
| 10 | 26 | 1045 | 3.4 | 1.40 | — | — |
| 11 | 30 | 1150 | 3.4 | 1.34 | 11 | — |
| 12 | 33 | 1264 | 3.4 | 1.28 | — | — |
| 13 | 37 | 1391 | 4.4 | 1.22 | — | — |
| 14 | 42 | 1530 | 4.4 | 1.16 | — | — |
| 15 | 47 | 1683 | 4.4 | 1.10 | — | 1 |
| 16 | 53 | 1851 | 4.4 | 1.04 | 14 | 1 |
| 17 | 59 | 2036 | 4.4 | 0.98 | — | 1 |
| 18 | 66 | 2240 | 4.4 | 0.92 | — | 1 |
| 19 | 74 | 2464 | 4.4 | 0.86 | — | 1 |
| 20 | 83 | 2710 | 4.4 | 0.80 | — | 1 |
| 21 | 93 | 2982 | 4.4 | 0.74 | 16 | 1 |
| 22 | 104 | 3280 | 4.4 | 0.68 | — | 1 |
| 23 | 117 | 3608 | 4.4 | 0.62 | — | 1 |
| 24 | 131 | 3968 | 4.4 | 0.56 | — | 1 |
| 25 | 147 | 4365 | 4.4 | 0.50 | — | 2 |
| 26 | 164 | 4802 | 4.4 | 0.44 | 19 | 2 |
| 27 | 184 | 5282 | 4.4 | 0.38 | — | 2 |
| 28 | 206 | 5810 | 4.4 | 0.35 | — | 2 |
| 29 | 231 | 6391 | 4.4 | 0.35 | — | 2 |
| 30 | 259 | 7030 | 4.4 | 0.35 | — | 2 |
Two steps decide the shape of a run. Round 5 is where zombie speed first outpaces early-round DPS growth. Round 13 is the wall: 4.4 m/s is faster than the 4.2 m/s player walk, and slower only than a 5-second sprint. Every measured death in the balance matrix landed at rounds 10–18.
Per-zombie speed carries a seeded ±10 % jitter applied once, by the AI system.
Canonical source:
src/systems/economy/balance.ts. The tables below are a snapshot of that file for readers; if the two ever disagree, the file wins.
Doors — priced by destination zone. Only these four are purchasable; Z5 loops back to Z1 for free, so the map is a closed kiting ring.
| Door | Cost |
|---|---|
| Z1 Spawn Bay → Z2 Machine Shop | 1 000 |
| Z2 Machine Shop → Z3 Cold Storage | 1 600 |
| Z3 Cold Storage → Z4 Reactor Corridor | 2 300 |
| Z4 Reactor Corridor → Z5 Anvil Chamber | 3 300 |
Buff stations — max 4 held at once, all lost on death. The power switch is in Z4 and is free; every station is inert until it is thrown.
| Buff | Cost | Zone | Effect |
|---|---|---|---|
Steady Aim |
3 500 | Z4 | Recoil ×0.65, head hit-zone ×1.15 |
Ironhide |
4 000 | Z3 | Max health 100 → 250 |
Second Wind |
7 000 | Z5 | One automatic self-revive, consumed on use |
Quickhands |
7 500 | Z5 | Reload duration ×0.5 |
Overclock |
13 000 | Z4 | Fire rate ×1.33 |
Fixtures
| Fixture | Cost | Notes |
|---|---|---|
| Power switch (Z4) | 0 | Gates all five buff stations and the Anvil Station |
Cache Roulette |
950 / pull | 6 s spin, 10 s grab window, 12 % relocation event, 3 anchors |
Anvil Station (Z5) |
8 200 | Damage ×2.2, magazine ×1.5, penetration +1, generated name suffix |
Weapons — wallBuy 0 means the weapon has no wall anchor and is reachable only through the Cache Roulette. ammoBuy 0 means ammo is unbuyable.
| Weapon | Display name | Wall buy | Ammo refill |
|---|---|---|---|
sidearm |
Mark I Sidearm | — (starting weapon) | 1 500 |
smg |
Vector SMG | 1 400 | 3 200 |
ar |
Lattice AR | 1 800 | 3 900 |
pumpShotgun |
Breaker 12 | 2 200 | 3 900 |
lmg |
Drumfire LMG | 2 700 | 4 600 |
boltSniper |
Longline Bolt | Cache only | 4 600 |
autoShotgun |
Cyclone Auto | Cache only | 4 300 |
burstRifle |
Tercet Burst | Cache only | 4 100 |
arcLance |
Arc Lance | Cache only | — |
Scrip awards — spec values from the design contract, restored after a tuning pass cut them; do not re-cut them.
| Event | Scrip |
|---|---|
| Starting balance | 500 |
| Non-lethal hit | 10 |
| Kill | 60 |
| Headshot kill | 130 |
| Melee kill | 130 |
| Barrier plank repaired | 10 |
| Full round survived | +50 |
From the balance simulation matrix — 30 rounds × 3 skill levels × 5 seeds, 15 full headless games, bot-driven through player.setInput() only. These are the shipped figures, matching the final matrix in QA_REPORT.md §LOOP 2 CONTINUED:
| Bot skill (headshot intent) | Mean death round | Ironhide affordable |
Anvil Station affordable |
|---|---|---|---|
| low — 35 % | 10.2 | round 6 (4 of 5 seeds) | round 9 (3 of 5 seeds) |
| mid — 55 % | 12.8 | round 6 (all 5 seeds) | round 9 (all 5 seeds) |
| high — 75 % | 14.6 | round 6 (all 5 seeds) | round 9 (all 5 seeds) |
Skill separates monotonically: +2.6 rounds from low to mid, +1.8 from mid to high. The mid bot opens the Z5 door in 5 of 5 seeds and buys the Anvil Station in 1 of 5 (round 14); high skill buys it in 2 of 5. Time-to-clear rises from round 10 onward. Every completed round across the matrix spawned exactly zombiesInRound(r) zombies — hard equality, not a tolerance.
| Directory | Owns | One line |
|---|---|---|
src/types/ |
frozen contracts | Every interface in the game. Imports nothing at runtime and never imports three.js — which is what keeps the sim headless. |
src/core/ |
primitives | Seeded Rng (mulberry32 + FNV-1a), typed EventBus, ServiceRegistry, fixed-timestep loop, math helpers, object pools, the one sanctioned log wrapper. |
src/engine/ |
rendering | Three.js boundary: code-generated texture atlas and materials, instancing manager, fixed-capacity GPU pools, frustum/LOD culling, lighting + fitted shadow frustum, single-pass post FX, F3 debug overlay. |
src/level/ |
Blackline Facility | Five-zone room graph, procedural geometry and props, AABB BVH collision, 192×116 nav grid with door/barrier gating, 24 barriers × 6 planks, 20 interactable anchors. |
src/systems/player/ |
the player | Capsule sweep movement, stamina and sprint, mantle, crouch, health / downed / bleed-out, camera FX and recoil kick, hardware input capture and pointer lock. |
src/systems/weapons/ |
gunplay | Nine data-driven weapons, hitscan ballistics with penetration, spread and recoil, pooled grenades, melee, procedural viewmodels, buff and Anvil multipliers. |
src/systems/ai/ |
the dead | Struct-of-arrays arena (24 live / 32 slots), Dijkstra flow field with amortised rebuild, 7-state FSM, zero-allocation hit-zone raycast, Ether Hound and Rendman variants. |
src/systems/director/ |
pacing | The frozen round curves, round phase machine, biased spawn scheduling, Scrip awards, soft-lock watchdogs. |
src/systems/economy/ |
Scrip | BALANCE (the single tuning surface), ledger, power gate, five buffs, four doors, Cache Roulette, Anvil Station, wall buys, allocation-free interaction raycast with per-anchor cooldown. |
src/audio/ |
sound | Raw WebAudio only. Oscillators, noise buffers, biquads and feedback-delay reverb; positional voices with zone occlusion; adaptive music bed; tab-blur resume. |
src/ui/ |
screens | DOM-over-canvas HUD, main menu, settings (the whole accessibility surface), pause, round transition, game over, localStorage high scores, dev console. No React. |
src/game/ |
assembly | Game context and reseedable RNG tree, bootstrap ordering, the state machine that joins systems together, the rAF loop, and createHeadlessGame(). |
tests/unit/ |
correctness | Vitest. Curve math, hit-zone resolution, purchase atomicity, buff stacking, the weapon state machine, flow-field correctness, RNG determinism, the Cache Roulette draw distribution, the IP scan. |
tests/sim/ |
fairness | Vitest. The 30 × 3 × 5 headless matrix plus a determinism fingerprint, driven by a scripted bot in tests/sim/harness/. |
tests/e2e/ |
the real thing | Playwright. Boot, pointer lock, shooting, round advance, pause, every accessibility control, game over, restart cleanliness, AudioContext resume, a 3-minute memory soak. |
tests/perf/ |
budgets | Playwright. Draw calls, CPU frame time, hot-loop allocations, shadow-frustum fit, bundle size. |
One file: src/systems/economy/balance.ts. Every door price, buff cost, Cache Roulette cost, Anvil cost, Scrip award and per-weapon price lives in the exported BALANCE object, and every purchase path reads it. Nothing else under src/systems/economy/** is permitted to hard-code a cost. Each field carries a comment saying what raising or lowering it feels like in play.
Then re-measure:
npm run test:simTwo cautions from the measured record, both real:
- Ammo price is the sharpest lever in the table, and the game sits on a cliff. Moving the AR refill from 3 600 to 3 900 — 8 % — moved the mid-skill mean death round by 4.4 rounds, because a run either sustains fire long enough to bank the Anvil Station or it does not, and the Anvil then decides the next six rounds.
- Per-seed spread at n=5 is ±3 rounds, so the standard error on any mean from this matrix is roughly ±1 round. Two configurations whose means differ by less than that are not distinguishable. Do not tune against a single run.
Two edits, in this order:
src/systems/weapons/defs.ts— add theWeaponDef: damage, RPM, magazine, reload timings, spread, the deterministic per-shot recoil table, penetration,cacheWeight, and aViewmodelSpec(a procedural geometry recipe — there is no model file to author).src/systems/economy/balance.ts— add the matching entry toBALANCE.weaponCosts(wallBuy/ammoBuy).
defs.ts projects the contract's wallBuyCost / ammoBuyCost fields out of BALANCE.weaponCosts, so pricing is written exactly once. A unit test pins that projection for every weapon, which means forgetting step 2 fails a test rather than silently shipping a free gun. Add the WeaponId to the union in src/types/weapons.ts and the compiler will find every remaining site.
The new weapon also needs an acquisition route — a wall anchor in src/level/anchors.ts with a non-zero wallBuy, a positive cacheWeight, or both. tests/unit/cache-draw-distribution.test.ts asserts that the union of the two routes covers every WeaponId, so a weapon with neither fails the suite instead of shipping as dead content.
src/systems/director/curves.ts — deliberately outside the balance table. Zombie count, health, speed, spawn interval, hound rounds and Rendman counts are frozen exact math with standalone exported functions, and unit tests assert them value-by-value. They are excluded from BALANCE on purpose: the economy is meant to be tuned freely, and the difficulty curve is not. Changing anything here means updating tests/unit/director-curves.test.ts in the same commit, and every function must stay total (non-finite and out-of-range inputs clamp through sanitizeRound).
| Command | Gate | What it proves |
|---|---|---|
npm run typecheck |
G1 | tsc --noEmit, strict, zero any in src/systems/** |
npm run lint |
G2 | ESLint 9 flat config, --max-warnings 0. Also enforces the Math.random ban and the console ban. |
npm run test:unit |
G3 | Vitest + v8 coverage; ≥85 % lines on src/systems/** |
npm run test:e2e |
G4 | Playwright, real Chromium, zero console errors |
npm run test:sim |
G5 | The 30 × 3 × 5 headless balance matrix |
npm run test:perf |
G6 | Draw calls, CPU frame time, allocations, shadow fit, bundle |
npm run build |
G7 | tsc --noEmit && vite build into dist/ |
npm run verify |
— | typecheck + lint + unit + sim + build |
npm run format |
— | Prettier over the claude-zombies |
Playwright browsers are not installed by npm ci. Before the first test:e2e or test:perf run:
npx playwright install --with-deps chromiumEvery option below is on the Settings screen, persisted to localStorage under claude-zombies:settings:v1, and covered by end-to-end tests. Corrupt or out-of-range stored values fall back to the default field rather than propagating into gameplay.
| Option | Range / values | Notes |
|---|---|---|
| Remappable keys | all 21 actions | Every action in the Controls table above, primary and alternate slots. Press-to-bind; Esc cancels. |
| Field of view | 70–110° | Clamped on load. Applied before the camera initialises, so a persisted value is honoured from the first frame. |
| Mouse sensitivity | slider | Radians per pixel of raw movement. |
| Invert Y | on / off | — |
| Reduce motion | on / off | Kills camera shake and head bob. |
| Colourblind-safe hitmarkers | off · protanopia · deuteranopia · tritanopia | Recolours hitmarkers and damage cues for safe contrast. |
| Subtitles | on / off | Text for audio cues — the game is fully playable with sound off. |
| Master volume | 0–1 | — |
| SFX volume | 0–1 | — |
| Music volume | 0–1 | — |
| FPS overlay | on / off | Also bound to F3. |
There is not a single binary media file in this repository, or in dist/. No .glb, .fbx, .png, .jpg, .jpeg, .mp3, .wav, .ogg, .webp, .hdr or .ktx2 — anywhere. That is asserted by a standing test, not by convention.
- All geometry is authored in code. The facility, its props, the zombies and every weapon viewmodel are built from parametric recipes at boot.
ViewmodelSpecis a geometry recipe, not a file path. - All textures are generated to
<canvas>at boot and packed into a single atlas, which is why the entire 92.8 × 54.8 m facility renders in 38 draw calls. - All audio is synthesised through raw WebAudio — oscillators, noise buffers, biquad filters and a feedback-delay reverb. No Howler, no audio files, no samples.
The measured result: the whole of dist/ is about 200 KB gzipped against a 900 KB budget — roughly 22 % of budget used and 700 KB of headroom. (Last measured build, the root-path Cloudflare build serving live: 200.52 KB across 6 files — 121.17 KB Three.js, 73.92 KB game, 3.32 KB visual chunks, 1.44 KB HTML, 0.66 KB favicon.) Sizes here are KiB at zlib level 6, matching what the budget test prints; Vite's own build log reports a slightly larger figure because it counts in 1000-byte kB and omits public/ files it merely copies. The Three.js chunk alone is ~121 KB gz, so the entire game — level, nine weapons, AI, economy, UI, audio synthesis — costs under 80 KB gzipped.
Bundle size is a standing regression test (tests/perf/bundle-budget.spec.ts), measured at zlib level 6 because that is what a CDN actually serves.
Paste the link into Slack, iMessage, Discord or Facebook and it unfurls with a full-bleed 1200 × 630 preview card — which is, of course, not a file either.
og:image has to be a raster format (Facebook and iMessage will not render an SVG), and a committed .png would violate the zero-asset rule and fail two tests. So the card is synthesised at the edge on request by a Cloudflare Pages Function, functions/og.png.ts: satori lays the card out from a flexbox tree, resvg rasterises it to PNG, and both run as WASM inside the Worker. Typefaces come from the Google Fonts API on a cold isolate and are then held in module scope. The response is cached for a day in the browser and a week at the edge, so the render itself is rare.
Nothing about this lands in the repository or in dist/ — functions/ is deployed alongside the static bundle, never bundled into it, so the asset ban and the 900 KB budget are both untouched. The same discipline extends to the icon: public/favicon.svg is hand-authored vector (SVG is a text format), and /favicon.ico is a 301 redirect to it served by functions/favicon.ico.ts rather than a binary .ico.
If every typeface fetch fails, the Function falls back to a hand-rolled PNG encoder — raw IHDR/IDAT/IEND chunks, CRC-32 by hand, CompressionStream('deflate') for the zlib stream — so a scraper is never served a 5xx it will cache.
| Suite | Result |
|---|---|
| Unit (Vitest) | 292 / 292, 13 files, 93.25 % lines on src/systems/** (threshold 85) |
| End-to-end (Playwright) | 39 / 39, 0 console errors, incl. a 3-minute memory soak with 0.0 MB heap growth |
| Simulation (Vitest, headless) | 79 / 79 |
| Performance (Playwright) | 8 / 8 — 43 draw calls at 24 zombies (budget 1 500); p95 game CPU frame 0.5–1.6 ms (budget 8 ms) |
| Bundle | 200.52 KB gz across 6 dist/ files (budget 900 KB), 0 binary media files |
| Live smoke (Playwright) | 2 / 2 against the deployed live URL on a real GPU — see Known limitations |
Every suite except the live smoke runs on every push, on Node 20, via .github/workflows/ci.yml. The live smoke is opt-in (LIVE_URL) because it targets a deployed site rather than a preview server and needs a GPU no CI runner has.
Claude\Zombies is original work inspired by the round-based zombie-survival genre as a whole. A unit test scans every file git tracks or would track — case-insensitively, as substrings — against a denylist of publisher, studio, title, map, character, perk and enemy names from commercial franchises. Latest full run: 0 matches for 134 denylisted terms across 165 enumerated files (164 scanned — see the single exclusion below).
The scan excludes exactly one path — the fixture that defines the denylist, because a denylist necessarily contains the strings it forbids. Nothing under tests/** is reachable from index.html or src/main.ts, so that file never enters dist/. Four meta-assertions stop the scan passing vacuously, including a planted-term detection.
Live now at https://zombies.jwhitton.com/, hosted on Cloudflare Pages (project claude-zombies, also reachable at https://claude-zombies.pages.dev/). The running build serves /assets/index-BeQyZDjN.js from the domain root. See DEPLOY.md for why the bundle hash, not the commit SHA, is the thing worth quoting.
The Pages project is direct-upload, not Git-connected. Pushing to
maindoes not redeploy the site..github/workflows/ci.ymlstill runs the full gate ladder on every push, but publishing is a deliberate, manual step. This is a real operational caveat:maincan be arbitrarily far ahead of what is actually served, and nothing will warn you.
Redeploying is one command, from a clean working tree:
npm run deploy:cfThat is build:cf (which pins --base=/) followed by wrangler pages deploy dist --project-name=claude-zombies --branch=main. It needs wrangler on PATH (npm i -g wrangler) and an authenticated Cloudflare session (wrangler login).
Run it from the repository root. Pages Functions are discovered in
./functionsrelative to the working directory, not relative to thedistargument. Deploy from anywhere else and the static bundle still uploads perfectly — but/og.pngand/favicon.icosilently vanish. A successful deploy prints✨ Uploading Functions bundle; if that line is missing, the Functions did not ship.
The other targets in the table below serve the static bundle only. functions/ is Cloudflare-specific, so on Vercel, Netlify, Docker or any plain web server the social card and the /favicon.ico redirect are simply absent — the game itself is unaffected.
The repository still ships ready-to-use configuration for the other targets. Full copy-pasteable instructions, including the required BASE_PATH for each, are in DEPLOY.md.
| Target | Config | BASE_PATH |
|---|---|---|
| Cloudflare Pages (live) | npm run deploy:cf — see DEPLOY.md |
/ (pinned by --base=/) |
| Vercel | vercel.json |
unset (/) |
| Netlify | netlify.toml |
unset (/) |
| Docker + nginx | Dockerfile, nginx.conf |
unset (/), or a build arg |
| GitHub Pages — retired | workflow deleted; kept as reference | /claude-zombies/ |
The output is plain static files. Any web server will do — there is no backend, no API, and the game makes zero runtime network requests after load.
- WebGL 2 is required. There is no WebGL 1 fallback and no software path.
- Pointer Lock API for mouse look.
- Web Audio API for all sound. The
AudioContextis resumed onvisibilitychangeand on the next user gesture, so audio survives tab blur. localStoragefor settings and high scores. The game runs without it, losing only persistence.- ES2022 output — current Chrome, Edge, Firefox and Safari.
Desktop, keyboard and mouse. There are no touch controls.
Stated plainly rather than omitted.
-
Real-GPU frame time was measured exactly once — on one machine, against the live site. Every automated browser timing in this repository comes from ANGLE/SwiftShader software rasterisation in headless Chromium, because no CI runner has a GPU. Draw calls, triangle counts, bundle size and allocation behaviour are exact and hardware-independent under SwiftShader; frame time is not. In one demonstration,
renderFramemeasured p95 1.0 ms in one run and p95 41.2 ms in another on the same build, purely because the host was busy — a figure that moves 40× with unrelated CPU load is measuring the rasteriser, not the game. The performance gate is therefore asserted against the game's own JavaScript (fixedStep + frameStep), which is identical on any hardware and measured at p95 0.5–1.6 ms.The
livePlaywright project (tests/e2e/live-smoke.spec.ts) closes the gap once, by hand, against the deployed live URL with the SwiftShader flags removed. Measured at 1920 × 1080, backbuffer confirmed in-page, on a Qualcomm Adreno X2‑90 integrated GPU through ANGLE/D3D11, WebGL 2:Scene Draw calls Triangles Median frame p95 Max fps Round 1, 6 zombies 46 11 833 8.30 ms 9.10 ms 10.50 ms 120.5 24 walkers at round-13 stats 45 15 961 8.30 ms 9.20 ms 11.20 ms 120.5 The run above was then repeated independently against the same deployment, and the finding reproduces: 46 draw calls / 11 917 triangles organic and 46 / 16 081 loaded, median 8.30 ms in both scenes (p95 9.10 / 9.00 ms, max 10.80 / 10.20 ms), 120.5 fps. Draw calls and triangle counts move only with view-dependent culling; the frame cadence does not move at all.
Quadrupling the zombie count moved the median frame interval by 0.00 ms: the cadence is pinned to the display's 120 Hz vsync in both scenes, so the true headroom is larger than these numbers can express and the measurement is an upper bound on delivered fps, not on capability. That is twice the 60 fps target, at the stated resolution, on integrated graphics — so the §2 constraint is met on this hardware. It remains a single sample from a single GPU vendor, and no other machine has been tested. See
QA_REPORT.mdrisk R4. -
The Anvil Station is a late, narrow reward, and that is a forced trade. It is affordable on the income curve at round 9 in all 15 simulated runs, but it is actually purchased in only 1 of 5 mid-skill runs (round 14) and 2 of 5 high-skill runs. Measured across fourteen tuned configurations, the mid mean death round runs 12.0 / 13.0 / 14.4 / 15.6 / 17.4 as Anvil purchases go 0 / 1 / 2 / 3 / 4 of five seeds — the upgrade is worth four to six rounds of survival, so "a mid player buys the Anvil" and "a mid player dies around round 12" cannot both be true. The shipped configuration takes the pacing target and accepts the narrow purchase rate. Full reasoning in
QA_REPORT.md§LOOP 2 CONTINUED. -
The audio has never been listened to by a human. It is synthesised, tested for correct cue routing and
AudioContextlifecycle, and measured — but nobody has judged whether it sounds good. -
Single-player only. No networking of any kind, by design.
-
Desktop only. No touch input, no mobile layout, no gamepad support.
-
arcLanceammo cannot be refilled. It has no wall anchor andammoBuy: 0. That is a deliberate design property of the Cache Roulette gamble, not an oversight — a pull can hand you a weapon you cannot resupply.
MIT © 2026 Claude\Zombies contributors.
Three.js is used under its own MIT license.