A browser-based side-scrolling bullet heaven — ride the left flank while a swarm of up to 2,500 enemies streams in from the right, auto-fire five weapons, level up (each weapon evolves at max), and survive ten minutes to face the boss. Built with PixiJS v8 and TypeScript; no game engine.
The genre is light on rendering and heavy on entity throughput, so swarmr is built as a systems problem, not a graphics problem: a fixed-timestep simulation over structure-of-arrays entity pools, a uniform-grid spatial hash, and zero per-frame allocation in the hot path. It holds 2,000+ enemies at frame budget (≈2 ms logic / <1 ms render) with a flat heap.
brac.github.io/swarmr — runs in any modern browser, no install.
- Move with
WASDor the arrow keys — you hold the left side as the world scrolls past; that's the only control, and every weapon auto-fires downrange to the right. - Grab the cyan gems — sparse and valuable (≈50 over the whole run). They lie on the ground and the world scrolls past them, so move to intercept; ones in magnet range home to you.
- Each gem banks one upgrade → pick one of three to shape your build. A weapon's 5th pick evolves it into a powered-up form (gold card).
- Survive to 10:00, then defeat the boss (it advances from the right too) to win.
Enemies stream in from the right and the roster widens over time: fast runners (0:20) and goblin packs (1:00), heavy tanks (1:30), armored biters (3:00) and shelled carapaces (5:00). The two apex elites — the homing hellhound and the glass-cannon serpent — are held back until 7:00, so the hardest mobs only show up late. Every enemy also gains HP as the clock climbs.
They don't all move the same: some weave in on a sine wave, fast rush packs charge straight at you as a group, and slow walls of high-HP mobs advance and close in to pin you. Read the movement, not just the count.
| Key | Action |
|---|---|
WASD / arrows |
Move |
Space (hold) |
Charge the Ultimate — a 4s hold unleashes a wide beam that wipes the lane (boss immune) |
Esc |
Pause / resume |
1 2 3 |
Choose an upgrade (on level-up) |
R |
Restart (after death / victory) |
M |
Mute / unmute |
| Key | Action |
|---|---|
` |
Open the dev menu (set any weapon to base/+1/max/evolved, spawn-count slider) |
L |
Toggle god mode (ignore contact damage) |
K |
Toggle XP leveling (freeze the upgrade flow) |
] / [ |
Manual level up (opens the menu) / level down |
B |
Spawn the boss immediately |
Each weapon was added because it forces a new system to exist:
- Dagger — auto-fires at the nearest enemy. Pooled projectiles + spatial-hash targeting + collision. Upgrades fan out multiple.
- Sword — a melee blade that swings only when a mob is within striking range, cleaving everything in a forward arc. Non-projectile, area-overlap damage.
- Piercing Light — a fast ray fired at 45° toward the nearest enemy that reflects off the top/bottom edges (up to 5 bounces) and pierces everything it crosses.
- Axe — a gravity projectile lobbed upward that arcs down through the swarm with infinite pierce.
- Laser — a sustained beam fired downrange along your (locked-right) facing. A line-segment hitbox that pierces everything in the lane.
At its 5th upgrade each weapon evolves: the Dagger becomes a fast triple-stream, the Sword swings twice as fast with greater reach, Piercing Light fires both up and down with more bounces, the Axe an outward spiral of giant blades, and the Laser a reflecting beam that splits and shrinks across the swarm.
Hits roll ±15% damage variance and a 15% / 2× crit (crits render larger and red).
- Renderer: PixiJS v8 (WebGL), batched
ParticleContainers for the swarm/projectiles/gems - Audio: Howler.js with procedurally-synthesized SFX
- Language: TypeScript (strict)
- Build: Vite 6
- No game engine, no physics library
Requires Node 20+.
npm install
npm run dev # start the dev server (Vite)Open the printed local URL and press any key to begin.
npm run build # type-check + production build to dist/
npm run preview # serve the production build
npm run typecheck # type-check onlydist/ is a fully static bundle — host it anywhere (Cloudflare Pages, GitHub Pages, any static server).
The SFX in public/sounds/ are synthesized (grey-box arcade beeps) by a script. They're committed, so you don't need to run this — but to tweak them:
node scripts/gen-sounds.mjsDrop real audio files over the same filenames to replace them.
A single mutable GameState owns the whole world. Pure systems read input and mutate it on a fixed timestep; dumb views read it and draw. Any view can be destroyed and rebuilt from state on any frame.
- Fixed-timestep loop — 240 Hz logic decoupled from render via an accumulator; gameplay never reads wall-clock delta.
- Seeded PRNG (
mulberry32) — every random call goes through it, so runs are reproducible for debugging. - Entities as structure-of-arrays over typed arrays — the array is the pool: capacity pre-allocated once, active set packed in
[0, count), death is an O(1) swap-remove. Nonewin the loop. - Uniform-grid spatial hash — a flat counting-sort grid rebuilt allocation-free each tick; powers weapon targeting, separation, and collision.
- Zero per-frame allocation in the hot path. Damage numbers, for instance, are composed from pooled digit sprites (no
BitmapTextre-layout) so they never churn the heap regardless of how many are on screen. - All tunables in
src/data/— weapon stats, enemy types, the difficulty curve, XP/level math, the boss. Code reads data; you edit data.
src/
main.ts # bootstrap: state, views, loop, lifecycle (title/pause/restart)
core/
loop.ts # fixed-timestep accumulator
rng.ts # mulberry32
spatialHash.ts # uniform-grid broadphase
input.ts # keyboard
audio.ts # Howler observer (plays on state edges)
state/ # the world: gameState + SoA entity pools (enemies, projectiles, gems, …)
systems/ # pure update logic: movement, spawn, collision, weapons/, gems, boss, upgrades
views/ # renderer (Pixi), hud (DOM), upgradeMenu, devMenu, perfOverlay
data/ # all tunables (weapons, enemies, waves, xp, boss, player, combat)
scripts/
gen-sounds.mjs # synthesizes public/sounds/*.wav
Targets, held at the 2,000-enemy count:
- Logic tick ≤ 4 ms — currently ~1–2 ms
- Render ≤ 8 ms — currently <1 ms
- Flat allocation profile during steady-state play (verify with the browser's heap timeline)
A live overlay (top-left) shows fps, logic/render ms, tick count, and enemy count.
- Sprites — 16×16 tile atlas from Kenney (public-domain / CC0 game assets).
- Sound effects — procedurally synthesized in-repo via
scripts/gen-sounds.mjs.
No license — all rights reserved. The source is public to read, but ask before reusing.