A bullet heaven in one HTML file. No dependencies, no build step.
Move. Everything else shoots itself. Survive to 3:00, kill what shows up.
Five weapons that fire on their own, nine passives, five enemy types and a boss that will not let you outrun it. Commit to a build and your weapon evolves into something meaner. Crits, knockback, and a rare heart drop that the magnet deliberately ignores — the only thing in the game that ever pulls you toward the swarm.
Generated by Claude (Opus 5) in Claude Code — design, code, balance and documentation. The constraints are deliberate: one file, no dependencies, no art pipeline. Shapes, particles and ~20 lines of WebAudio do all the work.
Open index.html in a browser. That's the whole toolchain.
- WASD / arrows — move (this is the only input the game has)
- 1 / 2 / 3 or click — pick an upgrade on level up
- P / Esc — pause
- R — restart after death or a win
- T — settings and live tuning
- M — back to the main menu from a finished run
index.html?test— 32 logic asserts, in the browsernode run-tests.js index.html— same asserts, headlessnode render-test.js index.html— every draw path against a stubbed 2D contextnode sim.js index.html/node many.js index.html— headless balance runs with a bot playersh e2e-run.sh— end-to-end in a real browser: keyboard, mouse, settings panel, pause, restart, localStorage (needs Edge or Chrome)
many.js is the one that matters for tuning:
$ N=40 node many.js index.html
sloppy/random win 4/40 median run 192s median lv 14 evolved 1/40
decent/random win 8/40 median run 210s median lv 14 evolved 0/40
decent/smart win 22/40 median run 197s median lv 20 evolved 14/40
perfect/smart win 22/40 median run 196s median lv 21 evolved 12/40
Median run ~195s against a boss that lands at 180s, so nearly every run reaches the boss and the boss decides it.
The column that matters is how you spend level-ups, not how well you dodge: random picks win
8/40, focused picks win 22/40 off the same motor skill. Evolutions follow the same line — a
committed build evolves in about a third of runs, a scattered one basically never. That is the
intended shape. Batch-to-batch variance is wide, so compare profiles within one run of
many.js, not across runs. N=40 for a sample worth trusting; the default 12 is for a quick look.
Every level gives three cards drawn without replacement from everything currently legal — unowned weapons, owned weapons below max level, passives below max level. Levels queue rather than interrupt, so a big gem haul hands you several picks in a row.
Evolutions are the reward for committing to a build. Take a weapon to level 4 while its
paired passive is at level 2, and an EVOLVE card appears — and keeps appearing until you take
it, because an evolution left to the same random roll as everything else showed up in 2 runs out
of 40. Evolving consumes the weapon's slot rather than adding one.
| Weapon | pair with | becomes |
|---|---|---|
| Bolt | Multishot | Railgun — a lance that pierces the entire screen |
| Scatter | Sharpen | Flak — a wall of piercing shrapnel |
| Halo | Bloom | Event Horizon — a wide ring of heavy orbs |
| Nova | Overclock | Singularity — twin screen-clearing shockwaves |
| Lash | Sprint | Reaper — a full circle sweep, every time |
WEAPONS and PASSIVES are the whole content pipeline: add an entry and it shows up on cards,
in the HUD chips and in the tests automatically. See docs/ARCHITECTURE.md.
Press T in game. Every knob applies live — the game keeps running behind the panel, so you
watch the change land instead of restarting to see it. Values persist to localStorage.
Feel — volume, screen shake, damage numbers. Screen shake goes down to zero on purpose: motion like that is a real accessibility problem and turning it off should not mean editing source.
Difficulty and Progression — the balance constants, exposed rather than buried. Handy for
answering "what if the boss arrived at 1:30" without a rebuild, and for making the game easier or
harder than the default without touching code. Reset to defaults puts everything back.
The panel is plain HTML on top of the canvas — <input type="range">, a checkbox, real <label>
elements. That buys keyboard navigation, focus rings, screen-reader labels and touch support for
free, none of which a canvas-drawn menu would have without a lot of code.
The same values, if you would rather set the defaults directly:
| Knob | What it does |
|---|---|
spawnEvery(t) / batchSize(t) |
how fast enemies arrive |
hpScale(t) |
how tough they get |
xpForLevel(lvl) |
how often you level up (kept deliberately flat) |
ETYPES |
per-enemy stats |
WEAPONS / PASSIVES |
the upgrade tables — add an entry, it shows up on cards automatically |
BOSS_T, MAX_ENEMIES, IFRAMES |
boss timing, population cap, hit forgiveness |
EVO_WEAPON_LVL / EVO_PASSIVE_LVL |
how much commitment an evolution costs |
HEART_CHANCE |
how often a kill drops the only healing in the game |
VOLUME, SHAKE_SCALE, SHOW_DMG |
the player-facing settings |
Anything in the TUNABLES table is reachable from the panel; adding an entry there is all it
takes to get a labelled, persisted, tested control.
Damage per hit has to keep pace with hpScale or the whole thing stalemates at the
population cap with nothing dying. That was the single biggest balance bug found by the sim.
Marked in-source with ponytail: comments.
- Brute-force collision — O(bullets x enemies). Measured 0.01ms/update at 340 enemies. A grid hash is the upgrade path if that ever stops being true.
- No object pooling — GC handles a few hundred short-lived objects fine.
- No sprites — shapes and particles. Zero asset pipeline.
- No music — sound effects only, ~20 lines of WebAudio.
- Population cap instead of a spawn budget — a hard ceiling is one line and does the same job.
- No DPR scaling — crisp enough, and it halves fill cost on retina displays.
- docs/ARCHITECTURE.md — how it fits together, and how to add a weapon, an enemy or a passive
- docs/TESTING.md — the four test layers, what each one can and cannot see, and the three balance bugs the sim caught
MIT — see LICENSE.
Build the upload bundle (it is gitignored, since it is just a copy of index.html):
Compress-Archive -Path index.html -DestinationPath swarm-itch.zip -ForceDrop the zip on an itch.io project page and tick "This file will be played in the browser".
index.html must be at the zip root, which the command above guarantees.



