
OVERDRIVE needs a keyboard and mouse. Open this page on a computer to play.
WASD move Mouse look LMB fire RMB aim (rifles)Q grapple Space jump Shift dash Ctrl slide R reload 1–4 weaponsTab armory F3 practice (main menu) F4 field guide G grenade F parry E read / interact / finisher Esc pause (leave fullscreen from the pause menu, or hold Esc)
Overview
OVERDRIVE is an arena shooter in the spirit of ULTRAKILL: dashing, sliding, a grapple hook, parrying, several weapons and a style meter that rewards playing aggressively.
There’s no engine underneath. The game is about 44,000 lines of C++17 and 1,000 lines of GLSL on top of SDL2 for windowing and input, OpenGL 3.3 Core for rendering and GLM for math, with another 9,000 lines of tests. It builds with a Makefile on macOS, on Windows through MSYS2, and for the browser with Emscripten, which is the build running above.
I built it to learn what an engine actually does for you. The answer turned out to be mostly time: deciding when things happen matters more than how they’re drawn.
| At a glance | |
|---|---|
| Modes | Arena, Story, Fast (time trial), Endless, Boss, Act II and combat practice |
| Arenas | 5 in Act I, 5 in Act II, an encore, 7 time-trial rooms and 27 Story checkpoints across three chapters |
| Enemies | 18 regular types, 3 Hollowed variants, 6 mini-bosses, 9 bosses (one with a second form) |
| Weapons | Revolver, pump shotgun, bolt rifle, heavy sniper rifle, and a sword |
| Sound | 136 loaded effects, 332 synthesized voice cues, music synthesized live |
| Code | About 44,000 lines of game C++ and 9,000 lines of tests |
| Runs on | macOS, Windows, the browser (WebAssembly + WebGL2) |
The game
Modes and campaigns
- Arena runs four arenas (a sunset yard, a foundry with lava channels, a tower you have to climb, and a night-time reactor) of three waves each, then a boss, then the Sanctum: a huge arena under an eclipse where you duel the final boss, a sword-wielding knight.
- Story follows UNIT 24, a rejected robot looking for its creator, through THE CRADLE, THE DEAD SPAN and THE LIVING WARD. Escape nine connected factory rooms, defeat the Sorter, then carry your weapons into a wasteland of dead convoys, broken crossings and abandoned turbines. Then descend into a medical laboratory where the later rooms cross incompatible dimensions. All three chapters are playable from the Story menu.
- Fast is the Gauntlet, a time trial through seven rooms joined by boost tubes that fire you along at 26 m/s, a mini-boss in each room and a final boss racing you to the beacon, with a run clock, splits against your best run, par ranks, medals for every room, and ROOM ATTACK for any one room on its own.
- Endless throws generated waves at you in the reactor until you die.
- Boss offers ten fights across Arena, Fast and Story, including the Pacemaker, Sorter, Ballast and Successor, with upgrades to match and local best kill times. The Absolute’s two forms have separate entries. The original six fights also support the shared boss leaderboard.
- Act II unlocks when you kill the final boss: a second campaign beneath the first.
Waves aren’t always “kill everything”: some ask you to hold a zone, destroy the pylons the enemies pour out of, or survive a reactor overload. The arenas also change as a fight goes on: the sun sets, the lava rises, the platforms speed up.
Story: The Cradle
UNIT 24 wakes on a conveyor carrying rejected bodies into a furnace. The first-person boot sequence tests its optics and fingers, then hands control back: get off the belt. Its purpose field is empty.
The route descends into a scrap pit, climbs through maintenance ducts and assembly catwalks, pauses in an archive, then continues through sleeping pods, a freight elevator and scanner gates. Weapons come from numbered wrecks. Their last logs and the archive’s terminals tell the story in a line at a time; two hidden wrecks reward looking off the route. Room checkpoints keep the weapons and logs already found; local autosaves let you resume after closing the game. Press Tab during play to spend earned upgrade points in the armory; upgrades carry through retries and into Chapter 2.
At the top waits the Sorter, the inspection machine that rejected UNIT 24. Its carriage pursues while the scanner runs, then intercepts a predicted route with a committed gantry rush. Three physical press heads warn together and fall in sequence: amber targeting, a red lock with an audible tick, then a visible downstroke and retraction. Change route after the lock or parry the white claw. Later phases add staggered scrap volleys and burning furnace vents. Inspection shields can shatter under machinery impacts; four corner belt panels fail progressively while the connecting crosswalk and rails remain. Aimed hits into the open blue strip reward a successful dodge, while a parry staggers the machine. Win, and the gate opens onto an ash wasteland under a red sky. Continue from the chapter card with your equipment.
Story: The Dead Span
Outside the factory, a road crew is still clearing a road nobody uses. The second chapter crosses eight checkpoints: an ash approach, a dead convoy, a buried relay, a broken span, a turbine graveyard, the last toll booths, the Cut and the far road. The Longshot is recovered from UNIT 11; optional wreck records describe failed repairs and interrupted journeys.
The encounters change with the work. The convoy is one ambush across freight cover, with a sheltered route around it. At the relay, hold the service pad while a small security detail contests the repair, or take a detour to isolate its security feed and stop reinforcements. Cross the span by grapple or take the maintenance bypass. At the turbines, shoot three seized brake locks to restart the rotors and open the route; an upper maintenance deck offers another firing angle and a winch control. Release its suspended counterweight to strike enemies in the warned drop zone, then use the landed load as cover. Quiet stretches and optional records separate the fights.
Ballast works the Cut: a road grader on four articulated legs, carrying a turbine, a worn blade and a cable winch. It advances while lining up intercepting grading runs, then commits; failing brakes lead into a separately warned pivot and second pass. Jump its close blade sweep and flying asphalt, dash across the charge lane, or shelter from grit behind solid cover. Its white harpoon can be parried; shoot a caught cable to release it and expose the rear turbine. Physical impacts can shatter the marked windbreaks, changing the next approach. Each tool has its own warning and counter, and required attack visuals remain visible with ambient detail off.


Story: The Living Ward
Chapter 3 descends through ten medical testing rooms, from Admissions and Recovery to a quiet conservatory, a crossing over a deep pit and observation halls looking onto a nebula and an ocean trench. A first-person entry scene shows a routine motor test becoming impossible while its instruments keep reporting normal contact. Familiar beds, identification bands, service equipment and medical records recur as the surroundings change. The treatment floors stay bright, spacious and grounded.
Four new flesh chassis families introduce committed lunges, armored support plates, suspended firing lanes and announced dimensional crossings. Gallery controls release assessment locks; the late observation contacts raise a chosen physical bridge. At the bottom waits the Successor: an agile near-human body with a mechanical face, three phases, cuttable Suture anchors and white strikes the player can parry. Its original 176 BPM score adds a four-part synthetic choir and organ to fast percussion. Defeating it opens a corridor into a fractured garden that the player can walk through to finish the chapter.

Checkpoints, practice and atmosphere
Records open with E when you choose to read them. Story → Continue restores the latest saved checkpoint, equipment, upgrades, discoveries and defeated bosses. Saves stay on the current device or browser; starting a new Story run replaces that local slot. Checkpoint retries retain the weapons and records already found. Story combat alternates ambushes, repairs, moving machinery and traversal rather than repeating three waves in every room. The wrecks, signs and dialogue refer to particular jobs and failures along that route.
F3 on the main menu opens Combat Practice: eight repeatable lessons for parries, finishers, enemy grapples, shotgun jumps, grenade punches, Overdrive, movement and free practice. Targets return after death, damage cannot kill you, and the Armory is available for experimenting. F4, or Pause → Field Guide, reopens the combat explanations during a run.
Press F2 or backtick on the main menu for the developer level select. Story joins Arena and Fast there: jump to any of the 27 checkpoints with the equipment needed for that point, optionally enable god mode, use F5 to clear the current encounter and F6 to refill health. Practice runs do not enter the leaderboards.
Wind, drifting ash, falling grit, vent emissions and loose machinery give the route movement between fights. Settings → Video → Ambient Detail offers Off, Low, Medium and High, independently of resolution. Lowering it immediately removes optional particles and decorative work; press warnings, projectiles, targets and damaging wind fronts remain visible. Potion drops are rarer, and the Story bosses move faster and use follow-up attacks in their later phases.
Settings → Gameplay → Enemy Markers saves a choice of Low or Detailed. Low is the default: small icons above the last few enemies, with just one short direction or distance label when needed. Detailed keeps the full enemy names, distances and cover labels. Bosses and mini-bosses have no permanent tracking pings in either mode; health bars and attack warnings remain visible.
Act II
You fall through five arenas, each built around one idea:
- The Nave, a flooded cathedral. The water slows you on foot (slide to skim across it) and rises every wave until only the galleries stay dry.
- The Orrery. Two rings of platforms turn over a captive sun whose flare sweeps the arena.
- The Descent. A cage rides down a vast shaft, stopping at a different floor for each wave, to a pit where the Penitent kneels, chained to the walls. Shoot out its six anchors or the chains, or grapple an anchor and rip it free. Jump its low censer sweeps, slide under the high ones. When the last chain breaks, it stands up and comes for you.
- The Reliquary. Pieces of the four Act I arenas, torn loose and drifting over a void, that regroup between waves: scattered, a ring, a stack you climb, then a path to a dark hole.
- The Maw, a cavern round a black pool where the Leviathan rises: a colossal plated serpent with an eclipse for an eye. It crashes its head down along a strip it burns into the floor, spits orbs you can parry back into its eye, and rolls tides over the ring. Hurt it and it hunts you from under the floor; near death the cavern floods and it tries to swallow you whole.
Act II brings its own enemies: a Revenant whose soul flees to another relic when it dies and comes back unless you catch it, a Weaver that strings snaring wires across the gaps you jump, a winged Seraph that sweeps a beam after you, an Anchor whose field stops you dashing or grappling, and Hollowed variants of the old ones (enraged, splitting in two when they die, or haloed against everything but a headshot or a parry).
The encore: the Absolute
Kill the Leviathan and the eclipse breaks. You rise into !!!!!, a floating ruin at sunset, for an encore against the Absolute: a figure of white light with red eyes who is faster than you and fights like a boxer. He throws jabs, crosses, hooks, spinning backfists, roundhouse kicks and haymakers strung into combos, and he flies, crashing down onto a circle marked under you or diving fist first.
Every strike can be read two ways:
- By colour. White means parry, gold means dodge or block, red means get out of the way.
- By ear. A wind-up rises into the blow, a cue lands on the beat to act on (a bright ting to parry, a low doom to dodge, a double pulse to get out), then a boom whether it lands or not.
Up there, and only there, you get a sword: combos and a charged thrust that heal you as they land, plus a parry and block on the right mouse button.
Beat him and he doesn’t die. The eclipse swallows the ruin, the sky goes black under a ring of fire, and he comes back as MorningStar, half again his size, blood orange with white eyes, for a second fight harder and longer than the first. Only the strikes to parry still show on his body. Everything else is read from the floor: rings to jump, cracks to step off, lanes of fire to stand between.
Scoring, guns and difficulty
Every run is scored on style, speed and the damage you avoided, and a good enough score puts your name on that mode’s leaderboard. Kills earn XP, scaled by how stylishly you play, and levels buy upgrades for the four guns: a revolver, a pump shotgun, a bolt rifle (the Lancer) and a heavy sniper rifle. They’re built as one arsenal: blackened metal and bone, each glowing its own colour, with cells on the gun that show the rounds left.
Each gun has two mutually exclusive mods. Choose extra revolver penetration or stronger precision hits, extra shotgun pellets or stronger movement recoil, headshot ammo refunds or airborne Lancer accuracy, and explosive Longshot impacts or deeper penetration. Buying the mod unlocks both choices; swapping is free. M (controller X) previews the alternative in the Armory.
Four difficulties change how hard enemies hit, how fast they fire and how far they lead their shots.
Architecture
Game code is organised as states (menu, gameplay) that own their systems. The source is split by job: core/, game/, player/, enemies/, world/, render/, audio/, ui/, and one folder per boss holding its state, its mind, its hazards and its arena logic.
Collision is deliberately simple. The player and every wall or platform are axis-aligned boxes, overlaps are resolved by pushing the player out along the axis of least penetration, and a spatial grid, rebuilt whenever the level’s walls change, keeps each query to nearby geometry.
That simplicity had a hole in it. Which way to push depended only on where the player ended up, so a move fast enough to carry them past the middle of a box in one tick pushed them out the far side: a ground slam (40 m/s) through a thin rooftop, a long drop through a floor slab, a full-speed grapple through a thin wall. A test that slams onto every spawn point in both acts found 108 of 212 landings in Act I going straight through. Now the push remembers where the player started the tick: if they were clear of a box along an axis and now overlap it, they came in from that side and go back there.
Hard problems
Physics that doesn’t care about your frame rate
Movement in a game like this is tuned by feel: jump height, dash distance, how much speed a grapple builds. If physics runs once per rendered frame, all of that changes with the monitor. A 144 Hz display jumps differently from a 60 Hz one. So simulation runs on a fixed 1/60 s step, fed by an accumulator of real elapsed time:
while (accumulator >= PHYSICS_DT) {
prevCamPos = player.camera.position;
snapshotForInterpolation();
if (hitStopFrames > 0) {
--hitStopFrames; // freeze simulation, consume one tick
} else {
physicsTick(PHYSICS_DT, keys, parryKey);
}
accumulator -= PHYSICS_DT;
}(g_fixedDt is the footage mode: when recording the showcase video, every frame advances exactly 1/30 s however long it took to render.)
Two details do a lot of work here:
- The accumulator is clamped at 0.25 s, so a stall (a window drag, a breakpoint) can’t make the game try to catch up on hundreds of ticks at once, the classic “spiral of death”.
- Hit-stop, the brief freeze when a hit lands, is implemented by consuming ticks without simulating, so it’s frame-rate independent for free.
Running physics at 60 Hz on a 144 Hz screen would look like judder, so rendering interpolates between the last two physics states using whatever fraction of a tick is left in the accumulator. The camera, every enemy, projectile and pickup, and the moving platforms all keep their previous position for this, so the game is smooth at 240 or 360 Hz too:
inline void GameplayState::render() {
frametimer::Lap ftLap;
applyTextureQuality();
float alpha = (float)(accumulator / PHYSICS_DT);
renderAlpha = glm::clamp(alpha, 0.f, 1.f);
glm::vec3 renderCamPos = glm::mix(prevCamPos, player.camera.position, alpha);
Camera renderCam = player.camera;
renderCam.position = renderCamPos;Movement that rewards skill
The obvious implementation, velocity = direction * speed, feels dead: no momentum, and nothing to master. OVERDRIVE uses Quake’s acceleration model instead. It projects the current velocity onto the direction you’re pressing and only adds speed up to a cap along that direction:
// WASD acceleration — suppressed while sliding so momentum carries.
if (!sliding) {
glm::vec3 wishDir{0.f};
if (keys[SDL_SCANCODE_W]) wishDir += flatFwd;
if (keys[SDL_SCANCODE_S]) wishDir -= flatFwd;
if (keys[SDL_SCANCODE_D]) wishDir += flatRight;
if (keys[SDL_SCANCODE_A]) wishDir -= flatRight;
float accel = onGround ? acceleration : airAcceleration;
if (glm::length(wishDir) > 0.001f) {
wishDir = glm::normalize(wishDir);
glm::vec3 hVel{velocity.x, 0.f, velocity.z};
float currentSpeed = glm::dot(hVel, wishDir);
float cap = horizontalSpeed * (onGround ? wadeFactor(wadeDepth) : 1.f);
float addSpeed = glm::clamp(cap - currentSpeed, 0.f, accel * dt);
velocity.x += wishDir.x * addSpeed;
velocity.z += wishDir.z * addSpeed;
}
}Because the cap is on the projection, turning the camera while strafing in the air keeps finding directions where currentSpeed is below the cap, so a skilled player can gain speed beyond the normal maximum. That’s the same mechanic behind strafe-jumping in Quake and the movement feel of Titanfall and ULTRAKILL.
Air acceleration is about half of ground acceleration (42 vs 80), friction drops to a quarter during a slide, and it’s skipped entirely while grappling so the hook can build speed. Getting the grapple and dash to feel right took five separate commits of tuning and fixes.
Encounters you can read
An early version had cube enemies wandering two huge rooms, and it wasn’t fun: you couldn’t tell enemies apart at a distance, couldn’t see an attack coming, and spent most of the time looking for the last one. The redesign started from what each enemy should ask of the player:
| Enemy | What it does | What it asks of you |
|---|---|---|
| Husk | Fires slow orbs | Parry them back |
| Ripper | Crouches, then lunges | Dash |
| Sentinel | Paints you with a laser before its burst | Break line of sight |
| Brute | Slams the ground | Jump the shockwave |
| Mite | Explodes | Shoot it early, so the blast hurts its friends |
| Shieldbearer | Tower shield stops bullets from the front | Flank it, shoot over the rim, or parry its bash |
That only works if the player can read it, so every enemy is a rig of boxes on joints (hips, shoulders, wing roots), posed from its AI state each frame. Legs swing with the walk cycle, a Raptor’s wings flap, and arms come up to aim or overhead to slam during a wind-up.
Each rig is 11 to 42 boxes. The Leviathan, a serpent of fourteen plated segments, is 124, and the Absolute, a human-sized figure with jointed fingers, ribs, rune-lit gauntlets and a broken halo, is 168. All of them, plus death debris, projectiles, doors and jump pads, go to the GPU as one instanced draw call.
The Absolute taught me the walk cycle has to be tied to the ground, not the clock. He places himself rather than going through the physics step, so his cycle never advanced and he glided across the floor with his legs held mid-stride. His steps now advance by the distance he actually covers, one stride per stride’s length, so his feet stay planted.
Waves are run by a small director that keeps 6 to 10 enemies on the field and trickles the rest in, away from the player:
} else if (!queue.empty() && spawnTimer <= 0.f &&
(aliveCount + 1 + (int)queue.front().escort.size() <= maxAlive() || aliveCount == 0)) {
Queued q = queue.front();
queue.erase(queue.begin());
aliveCount += emitSquad(q, playerPos, out); // on the field now; don't call the wave clear this tick
spawnTimer = SPAWN_GAP;
}Counting the squad as alive the moment it spawns is a bug fix. Without it, the wave-clear check right below compared against the alive count from before the spawn, so a wave whose last enemy spawned into an empty field ended instantly. The boss wave is a single enemy, so it always did.
The director and the enemy AI have no OpenGL in them, which is what made this findable: a headless test drives the director through an entire simulated run, and a regression test now holds the boss wave open while the boss is alive.
Climbing, and a time trial
The fourth arena, the Spire, is built around height: a 26 m tower with ledges, bridges, a balcony and a summit. Each wave spawns a tier higher, and the riflemen up there won’t step off their perch (a ledge check in their steering treats a drop like a wall), so you have to climb to them. Rippers and Mites have no such check and jump down after you.
Lifts, sweepers and orbiting platforms connect the tiers, and you can grapple any of them. They needed almost no new machinery. A moving platform is an ordinary wall flagged as dynamic: it moves every physics tick and records how far it went, and it’s kept out of the spatial grid and tested every tick instead. The player remembers which wall it’s standing on, so riding a platform is just adding that platform’s movement before the physics step. A test sweeps every platform through two full cycles and fails if any of them ever passes through a wall.
Fast mode reuses the same wave director with a flag, and took three tries:
- A straight run of sections. It played like a hallway.
- Each section a long channel with a breather before the fight. Better paced, but still one corridor.
- The Gauntlet: rooms joined by boost tubes. A tube is the breather: health waiting, nothing shooting, and a ribbed tunnel that fires you along at 26 m/s. Doors split open as you sprint at them, lock behind you when a room’s fight starts, and the exit unlocks when the room is clear.
The seven rooms are a canal between raised walkways, five terraces climbing 20 m, islands and ferries over a void, a tower you fall through floor by floor, a pump room opening into a hall with galleries, and a courtyard with a lift shaft up to the beacon. Tests check that no enemy is placed in a tube, that every fight waits for its room, that the route really does change direction and height, and that every placed enemy appears exactly once, exactly where it was placed.
That version still played like an arena with a clock on it: every room was two waves of kill-everything, so your time was set by the slowest enemy to die, a sniper on a ledge or a bird circling overhead. The fourth pass rebuilt it around a mini-boss in every room. Kill it and the room is won: whatever is still standing falls apart with it, at a second on the clock each, so you choose between cleaning up and running. Each one asks for a different move, and each moves on an older enemy’s skeleton at a bigger size with a body of its own made in Blender: the Courier runs from you up the canal with the room’s key (hook it), the Bulwark hides behind a sluice gate (get behind it), the Cantor sweeps a beam down the terraces (climb through it), the Harrier skims low after a dive (hook it there), the Plumb Line opens its core after a volley, and the Foreman is shielded by its tethered crew. Later waves always come from ahead of you. A fifth pass gave every room a middle wave before its mini-boss, shorter than the first and from a new angle, that sets up what’s coming (a Shieldbearer to get round before the Bulwark, Raptors over the gaps before the Harrier, a Juggernaut before the Foreman); the medals were retimed, and times from the old layout were set aside rather than left as records nobody could reach. The run ends with the Pacemaker, a boss that races you up the tower’s balconies to the beacon and tries to light it at the end. Every room has medals timed from its fight to its clear, and ROOM ATTACK runs any one room on its own for them. Headless tests run each mini-boss’s mind in its own room and the Pacemaker up the tower, phase by phase, to its beacon.
An enemy worth parrying
Parrying was in from the start, but nothing made it necessary. The Juggernaut is built around it: its armor halves bullet damage, its slow siege shells come back for 400 if you parry them, and punching its overhead smash in the last 0.4 s of the wind-up breaks it, leaving it staggered and taking double damage.
The parry itself became a visible punch with a clank, a flash and a hit-stop, and its window now scales with the speed of the shot, so a sniper round is as fair to parry as a slow orb. Later the arms were rebuilt in Blender with five jointed fingers on each hand, every joint easing into its pose on its own spring so the fingers never move in lockstep: the parry now cocks by your cheek and cracks across as a backhand, and fanning the revolver brings your left hand over the gun to slap the hammer back for each shot.
A final boss built from wind-ups
The final boss needed to be a duel, so the Sovereign is a 3.5 m knight with a greatsword whose whole moveset is built from readable wind-ups:
- He dashes at you at 34 m/s, often dashing again straight out of the first one.
- Up close he chains a sweep each way into an overhead cleave whose shockwave you have to jump.
- If you climb to high ground he leaps up after you on a ballistic arc, solved each time for where you’re standing.
- At range he throws fans of crescent slashes you can parry.
Each attack has its own held pose (the blade drawn back low for a dash, wound to one side for a sweep, raised overhead for the cleave) and a follow-through, each driven by explicit joint angles layered over the shared humanoid rig. The answer to all of it is the parry: punch a sweep or the cleave in its last quarter second and his guard breaks.
He fights in the Sanctum, an arena more than twice the size of any before it, built for movement: an open floor to dash around him, a ring of 18 m pillars to grapple, raised corners and floating islands reached by jump pads, and platforms orbiting the middle. Tests drive him against a player who stands still (he connects 25 times in 25 seconds), one who kites him in a circle (he closes the gap with chained dashes) and one standing on an 11 m island (he lands on it).
Bosses that answer back
Every boss is held to the same bar:
- each attack has a tell you can see and a tell you can hear;
- the fight changes as it goes;
- standing in a corner or plinking from range gets punished;
- there’s always a skilful answer that pays off.
The Warden, the boss of the reactor arena, used to fail most of that: a damage sponge you could out-shoot from behind a pillar. It’s now a reactor-fed guardian in three phases. At first four conduits run from the pillars to its back and it takes half damage; shoot the nodes off the pillars and it reels. Every third attack it vents, its chest plates opening on a white-hot core that takes triple damage. At 60% it plunges its arms into the reactor, which throws out rings to jump while it sweeps a beam a pillar will stop. At a quarter it tears free into a meltdown, lunging at you against a 40-second clock.
The Sovereign got a tuning pass rather than new moves. Enraged, on the hardest difficulty, some of his wind-ups had shrunk to a fifth of a second, too quick to read, so every one now lasts at least 0.35 s (0.28 s inside a combo, after the first stroke has warned you). Every combo ends in a window of at least 0.8 s, and each phase change gives you a breath before the next stroke.
Whether a boss “punishes camping” is hard to judge by playing, so the tests check it. Scripted players run against each boss’s real AI in its real arena: one camps a corner, one sits on the highest perch, one kites round the edge, one only shoots from 30 m, and each has to take damage within eight seconds. A scripted “solid player” who dodges every tell and parries every other window also fights each boss to the end: about a minute and a half for the Warden, two minutes for the Sovereign.
The same scripted players found holes before anyone played:
- The Leviathan left a player perched on a ledge or running in a straight line alone while it was underground: the clock that triggers its spit stopped while it was hidden, and its crash landed where you had been. The solid player also killed the planned 9,000-health version in 104 seconds, so it now has 15,000 and takes about three minutes. Players still found it too easy, and the cause was one radius: the rule that makes it crash down on anyone hugging its pool reached halfway across the ring.
- The Absolute’s strikes kept closing in right up to the moment they landed, so no well-timed dash could get out of reach. They now commit for the last 30 % of their wind-up. A player perched on a pillar was safe too, because his teleports only landed on the floor: now he flies over and breaks the island under you.
- MorningStar’s shockwave, launched from the raised dais, couldn’t be jumped from the floor below it, and a player on a pillar dodged his drop every time.
Then the harness itself turned out to be wrong. The scripted player found the floor by looking down from its own height, and the encore’s floor, a disc built from 48 boxes, has gaps at its rim. One step into a gap dropped it 400 m under the floor, where it never found the floor again, so every one of MorningStar’s rings hit it however it jumped. That happened in 23 of 25 fights, so most of the numbers his tuning had leaned on came from a player standing under the world. And because the fight is chaotic, whether that player lived came down to rounding: the same check came out green on macOS and red on Linux.
With the harness fixed, the fight is judged over 25 runs that start a few metres apart instead of one. The solid player kills the Absolute in just under three minutes (the median), surviving about two fights in three, and MorningStar takes a little over three minutes more.
Islands that move
The Reliquary is the first arena where the floor itself changes between waves. Each relic is a group of boxes driven together along an eased path (the same system that carries the Descent’s cage), with a position for each wave, and the next wave waits until they’ve settled.
The awkward part was making sure no glide runs one relic through another. With eight debris stones, which stone goes to which slot in the next arrangement decides whether their paths cross. A small search over all 40,320 assignments per glide picked collision-free ones, and a test now replays every glide and checks every box against every other.
The test that checks you can’t cheese the new enemies found something worse: every walking enemy stepped straight off its relic into the void within a few seconds. Their edge check treated anything near their own floor height as solid, and on a relic their floor is the relic’s top, so open air looked as safe as stone. Over a void, enemies now only step where something is really under them.
A colossus that couldn’t touch you
The Penitent’s rules are all relative to the floor it stands on: a low sweep hits you if your feet are within a metre of that floor, its shockwaves hug it, and it lashes a chain at you if you perch more than 4 m above it. The tests built it standing on the pit’s floor and every rule passed. In the game it was harmless.
The shaft above the pit is open, so the “hard floor” for that stretch of the level is 60 m further down, and the game handed every enemy that number. As far as the Penitent knew, I was standing 60 m in the air: every sweep passed beneath me, every hazard lay on a floor nobody could see, and the chain lashed every few seconds and never connected.
A code review caught it before it shipped. Enemies in the shaft now stand on whatever is really under them, the moving cage included, and a new test drives the Penitent through the same floor rule the game uses. That review also found the fight’s other ways out (a band of distance where nothing could reach you, a ledge you could fight it from, a “shoot its back” weak point you could hit from the front), and each is now pinned by a test that failed first.
Headshots that match the model
Shots that clearly hit an enemy in the head kept doing body damage, for two reasons:
- The Juggernaut had simply been left off the list of enemies with heads.
- For everyone else, a headshot was decided by where the bullet entered the body box: above 80% of its height counted as the head. A body box is as wide as the shoulders, so when you aim up at a 3 m enemy, the ray enters the front face well below the head it’s about to hit. The reverse happened too: a Brute’s shoulder pads sat inside its “head” band.
Now each enemy has a real head hitbox, built from the same proportions as its model, so the head you see is the head you hit. The humanoids share a table of body measurements that both the rig and the hitbox read, and the box follows the animation: walking bob, a broken Juggernaut slumping forward, a Ripper’s crouch, a Raptor pitching into a dive. Because the box turns with the enemy, the code transforms its corners into world space and takes the axis-aligned box around them.
A headshot now also flashes the hitmarker gold. Tests fire rays from eye height at every humanoid’s head and chest and check which ones count.
Procedural materials and instanced geometry
There isn’t a single image file in the repository. Every surface (brick, concrete, riveted plate, adobe, marble, circuit panels whose traces glow, rune-cut basalt, limestone, engraved brass) is generated at startup from value noise and uploaded as a texture. The noise wraps at the tile’s edge, so the 4 m repeat never shows a seam:
// Value noise over the tile (u, v in 0..1) with cx by cy cells, wrapping at the edge
inline float noise(float u, float v, int cx, int cy, int seed) {
float x = u * cx, y = v * cy;
int ix = (int)std::floor(x), iy = (int)std::floor(y);
float fx = smooth(x - ix), fy = smooth(y - iy);
auto H = [&](int a, int b) { return hashI(wrap(a, cx), wrap(b, cy), seed); };
float a = H(ix, iy), b = H(ix + 1, iy), c = H(ix, iy + 1), d = H(ix + 1, iy + 1);
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
}The frame renders to an offscreen framebuffer, with enemy parts grouped into instanced draws by mesh, then goes through bloom: a bright-pass extract followed by ten separable Gaussian blur passes that ping-pong between two framebuffers, alternating horizontal and vertical:
bool horizontal = true;
for (int i=0;i<10;++i) {
glBindFramebuffer(GL_FRAMEBUFFER,bloomFBO[horizontal?1:0]);
blurShader.use();
blurShader.setInt("image",0);
blurShader.setInt("horizontal",horizontal?1:0);
glBindTexture(GL_TEXTURE_2D,bloomTex[horizontal?0:1]);
drawQuad();
horizontal = !horizontal;
}The optional CRT pass adds barrel distortion, chromatic aberration and scanlines on top, before the HUD and the weapon view model are drawn.
Everything renders into a 16:9 canvas at a fixed resolution and is then scaled into the window, while the HUD lays itself out in a virtual 1280×720. That makes resolution a single number, and the graphics quality setting picks it: 720p, 1080p, 1440p or 4K. Rendering above the screen’s own resolution is supersampling.
On top of that, anti-aliasing (4x MSAA by default) keeps the thin neon lines on the floors whole in the distance instead of breaking into dashes. Storing the scene in a packed 32-bit float format (R11G11B10F) instead of 64-bit RGBA16F halved what 4x MSAA cost.
| Resolution (M2 MacBook Air) | Frame time, AA off | 4x MSAA adds |
|---|---|---|
| 720p | 2.0 ms | 0.2 ms |
| 1440p | 3.5 ms | 1.3 ms |
| 4K | 6.2 ms | 3 ms |
Sound from somewhere
For most of its life the game played every sound effect the same way: through SDL_mixer’s channels, at a fixed volume, in both ears. An enemy winding up behind you sounded exactly like one in front of you. So the effects now go through a mixer of my own, running in the same audio callback that already synthesizes the music.
- 48 voices in four groups (the player, enemies, the world, the UI), each with a cap, so a crowd of dying enemies can’t drown out your own gun or the boss.
- Position. A sound placed in the world falls off with distance, pans toward its side and gets darker through a one-pole low-pass, both with distance and when it’s behind you.
- Variation. Every play varies its pitch and level a little, so ten shots don’t sound like one sample repeated.
- Reverb per arena. A small Freeverb-style reverb tuned to each space: dry in the open yard, metallic in the foundry, long and dark in the cathedral.
- No locks. The game thread never touches the voices; it pushes commands into a lock-free single-producer ring that the audio thread drains at the top of each buffer.
The mixer has no SDL in it, so the tests render it offline and measure the result: a sound on your right is louder in the right channel, the same sound behind you has less high-frequency energy, a full group gives up its quietest voice, and silence in gives exactly silence out.
A code review then caught the classic reverb bug. After a sound dies away, a feedback delay line decays toward zero but never reaches it: the values sink into denormal floats, where multiplying by the feedback rounds back to the same number. Apple Silicon handles denormals at full speed, so nothing showed up on my laptop, but on many x86 CPUs they’re many times slower, and WebAssembly can’t switch them off. The filters now flush anything below 10⁻¹⁵ to zero, and a test renders a minute of silence after a sound and counts the denormals left in the reverb: zero.
Enemies you can hear coming
Until recently every enemy made the same sound: one shared beep when any of them wound up an attack. Now every enemy type has its own voice, in the game’s “dark machine” style: a machine body (servos, hydraulics, gear grind, treads, rattling chains) carrying a hollow choir of synthesized vowels.
Size sets the pitch, so a Mite skitters and beeps high while the Penitent’s monk choir groans an octave below. Every attack has its own wind-up: the Brute takes a huge breath before it slams, the Juggernaut’s siren rises before each shell, the Sovereign sings a different syllable for every sword stroke, and the Penitent groans before a low sweep (jump it) and rings a bell before a high one (slide under it).
None of it is a sound file. 288 voice cues, with variants where appropriate, are synthesized in C++ when the game loads. The bank adds no recorded voice files to the download, and the tests measure every cue: each must land within 2 dB of its target loudness and never clip.
More voices meant a crowded wave could turn to mush, so the mix ranks sounds by what they’re for. Every wind-up within 40 m is heard, even out of sight, while only the nearest four enemies may chatter, and hurt sounds are rationed so a shotgun blast is one cry, not eight. While any wind-up plays, the chatter steps back a few decibels. Every sound’s level lives in one table, held by a test, instead of in volume numbers scattered across a hundred call sites.
A leaderboard everyone shares
Every run ends with a score: all the style you earned, plus a bonus for beating par or for each wave survived, minus the damage you took, times the difficulty. A score good enough for the top ten asks for a name.
On this page that goes to a shared board: a small serverless function on the site keeps each mode’s top 100 in a Redis sorted set. It cleans names to the characters the game’s pixel font can draw, rejects scores no real run could reach, and limits each address to ten posts an hour. The game itself never talks to the network: the page fetches the board into localStorage, where the C++ reads it exactly like its own saved board, and a finished run is handed to the page to post. Each boss has a board of its own, fastest kill first: the BOSS list shows the world record and who holds it beside your own best.
In FAST mode you also race your own best run: its route is recorded ten times a second and played back as a glowing runner, so you can see exactly where you’re gaining or losing time.
Porting it to the browser
The same C++ runs above as WebAssembly, drawing through WebGL2. Most of the port was small because WebGL2 is OpenGL ES 3.0, and every GL call the game makes already exists there. The differences that mattered:
- Shader dialect. The loader rewrites the
#versionline and adds default precision at load time, so the same.fragfiles serve both builds. ES bans implicit int-to-float conversion and initializers on uniforms, which two shaders needed fixing for. - Who owns the loop. A browser tab can’t block in
while (running), so the frame body moved into anAppstruct that the desktop build calls in a loop and the web build hands torequestAnimationFrame. - Escape belongs to the browser. Pressing Esc releases pointer lock before the game sees the key, so losing the lock mid-run pauses the game. In fullscreen, Chrome and Edge let the page take a keyboard lock on Escape, so pausing no longer throws you out of fullscreen.
- The mouse can lie. Under pointer lock, Chrome occasionally reports one enormous mouse delta, which snapped the view round 180°. The page asks for raw input where it can, and the game drops a single delta many times larger than the ones around it.
- Download size. The web build encodes its recorded effects to Ogg Vorbis and shares repeated model geometry. The download includes the runtime, models, sound and baked lighting. The player calculates the current uncompressed size from the shipped files; the game only loads when you choose to play.
- Shader compiles at the worst moment. The first time you fired, a Sentinel aimed or you scoped in, the browser built a new GPU pipeline and froze for about 200 ms. The first frames of a level now draw one invisible instance of each effect behind the title card.
- Real pixels. The canvas gets the screen pixels it actually covers (CSS size ×
devicePixelRatio), capped at the graphics quality’s resolution, instead of a fixed 1280×720 stretched to fit. - Saving. Emscripten’s filesystem is forgotten on reload, so settings and best times go to
localStorage.
On an M2 MacBook it holds 60 fps in Chrome, the same as the native build at that refresh rate.
Game feel
Most of the autumn went into how the game feels rather than what it contains. Each piece below is small; together they’re the difference between a game that works and one that hits back.
A camera that answers
Shake is driven by “trauma” and follows smooth noise, mostly as rotation, so small knocks barely register and big ones hit hard (the old shake never reset, so one boss shake made every later shake boss-sized). Shots, landings, jumps, slides and hits each kick the view on a damped spring, and a hit knocks it away from where it came from. The camera leans into strafes and bobs with a stride that drops each footstep on the bob’s low point. The guns sway behind your turns and tip into a strafe. All of it is unit-tested to move the same at any frame rate and follows the screen-shake and view-bob settings.
Jumps and frame pacing
A tap is a hop and a held jump climbs higher than before for the same time in the air, so every jump the levels were built around still makes it. Jump pads, tubes and the grapple keep their original arcs.
Above what the GPU can keep up with, a pacer stops the CPU from queueing frames behind it, so each frame starts from fresh input. That cut the GPU wait at the start of a frame from 3.4 ms to 0.15 ms at an unlimited frame rate on an M2 Air. Shots also fire in the frame you click, not on the next 60 Hz tick.
Fights that hit back
- Every round shoves. The push is the gun’s force over the enemy’s mass: a magnum round rocks a Husk back half a metre, a point-blank shotgun blast lifts it off its feet, a Brute barely moves.
- Stuns cancel attacks. Enough damage close together breaks an enemy’s footing and cancels its wind-up, so two shotgun blasts in a row stop a Brute’s slam.
- Finishers. A badly hurt or stunned enemy glows, and a finisher takes it for health and a full magazine. It used to share the parry key; after playing it I found the parry kept winning, so it has its own key now.
- OVERDRIVE used to only refill your dashes. Now it’s six seconds of harder, faster shots that never need a reload, earned by a run of kills rather than a few hits.
- Movement is an attack. The grapple yanks light enemies to your feet or zips you to heavy ones, a slide kicks, and a slam hits harder the further it fell.
- Fairness. Only a few enemies may wind up at once, and one doing it out of view gets a chevron at the screen’s edge.
On desktop the game logs what hurts you and what kills you, so the tuning can come from play rather than guesses.
Screens, shots and air
- Screen effects live in the final composite pass, which already reads every pixel, so they cost no extra full-screen pass. A dash brings a zoom blur, speed lines and colour fringe; low health drains the colour, closes red in from the edges and beats a heartbeat while the mix goes muffled.
- Shots that sound like they’re somewhere. Each shot probes the walls around you, and the nearest three send it back from their own side, delayed by the time sound takes to get there and back. A corridor slaps and an open yard barely answers. Rounds spark where they hit, brass lands at your feet, enemy shots whizz past your head.
- Each arena has its own air. Under the music, every arena and room has a background sound synthesized live: birds that give way to cicadas at nightfall, a furnace with distant clanks, running water and a pump’s beat, a clock in the Orrery, something breathing in the Maw.
- Light and contact. The neon and lava light whatever is near them (the 8 nearest lights each frame, faded so none pops in), and anything standing on a surface darkens where it meets it. On an M2 Air at 1440p, the textures, lights, shadows and room tone together cost about 0.6 ms a frame.
- The revolver became a hand cannon: a slab-sided magnum with a heat gauge and etched glyphs that glow on a slow breath, quickening as you fire, and a heavier shot. Its wall echoes had been replaying the dry shot a few milliseconds late, which comb-filtered into a hollow tone, so its walls now send back a darkened, smeared echo instead.
No way out of bounds
A scan treats every spot you could stand on in every arena and corridor as reachable (the floor, any box top, the far side of a wall) and checks you can get back by walking, dropping and jumping alone. It found 1,832 such spots behind the walls in Act I, which were fixed by tightening the arena limits, and it now runs on every build. For anything that moves, being pushed inside something solid for a quarter of a second puts you back on the last firm ground.
Tools
Guns and enemies made in Blender
Each gun’s shape, and the sword’s, is a glTF file of boxes on named joints (the revolver’s crane and cylinder, the shotgun’s pump, the rifles’ bolts) that the code moves and lights every frame. The ammo cells, muzzle flash and reload props stay in code because they depend on the moment.
Before the hand-written recipes were deleted, a test proved the files drew the same guns box for box across 1,169 states each. A viewer (./shooter --viewer) reloads a gun the moment it’s saved, and a Blender add-on with a live link can send edits straight from Blender. The shotgun’s vented heat shield and the redesigned sword were both built that way.
The enemies are next. The Husk, the first enemy you meet, is now a file too: its six joints (hips, torso, head, shoulders) sit where the code turns them, so the walk, the aim and the wind-up animations work unchanged. Moving it into a file was proven box for box against the old code rig, then it was rebuilt in Blender from 16 boxes to 88: rust plate over a dark frame, hot eyes behind a visor, a chest core and a reactor pack that flare as it winds up, and an orb cannon whose coils heat as the shot builds. Tests hold every box of its head inside its head hitbox, so the head you see is still the head you hit.
Then the game learned real meshes. Until then every part, from a file or from code, was drawn as a box. Now a part that isn’t one keeps its shape: the loader reads the geometry out of the file, and a part only counts as a box if every vertex sits on a corner of its bounds and every triangle lies flat on a side (a wedge’s corners sit on its bounds’ corners too, which a test caught). Each kind of mesh is one more instanced draw call. The Husk’s armour is now bevelled, its helmet rounded, and its barrel, coils, exhausts and joints are cylinders: 41 meshes, about 3,000 triangles, with no measurable change in frame time. Its debris breaks into the same shapes.
The five enemies you meet most after the Husk followed: the Sentinel (a lanky sniper with a cyclops eye and a rail rifle), the Brute (a furnace in its chest that throbs and flares as it winds up), the Ripper (a machine hound whose scythe blades brighten before it lunges), the Raptor (a mechanical hawk whose talons light as it dives) and the Mite (a bomb-beetle whose dome flashes as its fuse burns). Rather than copy each rig over by hand, a small exporter draws the code rig in dozens of states with a trace on, hangs every box on the joint it was drawn from, and works out its colour and which glow it follows; a test proved all five files matched their code rigs box for box before any of them changed. One test now runs over every enemy made in Blender: joints where the code turns them, every box of the head inside the head hitbox, feet on the floor, meshes kept, and the same after a save. It found an old bug on its first run: the Raptor bobbed in the air on the render clock while its head hitbox didn’t bob at all, so the head you aimed at could be 15 cm from the one that counted. Both now bob together.
The Juggernaut and the Shieldbearer came next. The Juggernaut is a steel-and-gold siege mech whose furnace core throbs, whose cannon arm heats as it charges a shell and whose gold hammer fist lights only while it winds up a smash; parried, it slumps forward with its core strobing. The Shieldbearer’s tower shield got a joint of its own, so the file’s shield is braced in front, drawn back for a bash and knocked aside when it’s parried, exactly as the code rig moved it. What the shield blocks is decided by angle, not by its shape, so the new model couldn’t change the fight. The exporter needed one more trick for them: the rings on the Juggernaut’s cannon are set at an angle on its arm, so it now finds the joint a part keeps a fixed offset from across every state, rather than only the frame it was drawn from.
Then the rest, bosses included: every enemy and boss model is a file now, from the Mite’s 24 parts to the Absolute’s 409. The Warden’s heart is a reactor behind an iris of six plates that draw back when it vents, the shot that matters; the Leviathan’s file holds its skull and jaw plus one plated body segment that the game lays along each stretch of its curving body, scaled to fit. Building them turned up three old rig bugs the new models would have shown off: the Penitent’s right arm hung inside its robe, the Weaver’s legs bent down through the floor, and the Anchor’s hunch tipped its toes under it. Your own arms on the sword and the parrying gauntlet are a file too.
Two things followed from real models. Deaths now break a body at its joints: every part on one joint (an arm, a leg, the head) flies off as a single piece holding its shape, the way the killing blow went, instead of a 300-part boss bursting into 300 fragments. And the arenas are dressed with props made the same way (market stalls, crates, broken columns, girder and carved pillars, a town on the Sunset Yard’s skyline): a box keeps its collision and the prop is drawn in its place, stretched to fit it exactly, baked into the static level mesh. A test holds every prop inside the box it dresses, so what you see is what you collide with. Pickups, enemy shots and the grapple’s claw are files as well.
All that geometry swelled the model files to 8.3 MB, mostly with copies: Blender writes a rivet once per rivet. A small lossless pass shares identical data and took them down to 3.8 MB, checked part for part and vertex for vertex against the originals. Served compressed, the download barely changed, because the compression was already finding those repeats; what it saves is memory in the browser and load time.
Balance on sliders
Every gun’s stats, every enemy’s and boss’s health, speed and timing, OVERDRIVE and the four difficulties are named in one table that points straight at the values the game reads. A panel on F1 (Dear ImGui, desktop builds only) freezes the game to change them live, and saves what changed to a text file that the game and the tests both load at startup, so the tests check the numbers that ship.
Measuring instead of guessing
- A frame timer (
--bench) prints a per-zone breakdown and the worst frames;--skip sky,bloom,…turns passes off to see what each one costs. It showed the game is GPU-fill-bound (the simulation takes about 0.1 ms). make testchecks physics, game logic, route traversal, objectives and boss fights with scripted players, without a GPU or window.make scanchecks escape routes from arena bounds. Nativemake test-devselects every Story checkpoint through the developer menu and renders its first gameplay frames after the handoff. It also checks real shots on turbine brakes, gate and retry behavior, and detail changes while paused.make test-web-storyrenders all three Story boss previews in WebAssembly with a restricted stack to guard against browser-only overflows.- CI builds and tests every push on Linux, macOS and the web.
What an audit turned up
Before putting the game in front of people I read it end to end and wrote the first headless tests. Most of what it found was invisible in a quick playtest, which is exactly why it had survived.
- Standing on a platform flickered between grounded and airborne. Gravity is skipped while grounded, so on the next tick the player no longer overlapped the box, fell a hair and landed again, every tick: the landing sound fired about 30 times a second. A ground probe (a box top within 2 cm under the feet counts as ground) took the count from 60 transitions to zero.
- Hitscan went through walls. The revolver and shotgun only clipped the tracer at the wall. A hit now counts only if the enemy is closer than the nearest wall along the ray.
- A use-after-free on every menu click. Clicking Start destroyed the menu while its own event handler was still running. State changes are now queued and applied between frames.
- Most of the level rendered at 15% brightness. The world shader darkens pixels near a face’s UV edge, but world-space UVs run past 1.0, so everywhere outside one tile counted as “on an edge”. Wrapping the UVs with
fract()fixed it. - Settings that did nothing. The volume slider never reached the mixer, and the field-of-view setting was ignored on the first run.
- Enemies stuck behind the furnace, one run in twelve. Run with 60 seeds, 5 Rippers spent the whole time zig-zagging along the furnace’s back wall. Enemies now notice when they’re not closing the distance and commit to going round one side.
Results
- Playable campaigns and challenges: three Story chapters with local saves, two arena acts and an encore, a time trial, Endless and Boss modes, four difficulties, shared leaderboards, checkpoints, XP and weapon upgrades, style scoring, and settings that persist (FOV, sensitivity, frame rate up to 360 Hz or unlimited, graphics quality, ambient detail, full key rebinding, screen shake, reduced flashing and colour-blind boss tells).
- Gamepad support, in the browser too.
- A soundtrack synthesized live in the audio callback, with a track per area that builds from the tubes into each fight, plus gunshots built from CC0 recordings with a synthesized thump and ring under each, held to their recordings’ body and brightness by a test.
- Consistent movement across refresh rates, from 60 Hz to unlimited.
- Procedural surface textures. Materials are generated at load; the guns, the sword, every enemy and boss, and the arena props are models built in Blender.
- Builds and runs on macOS, Windows and in the browser. It also compiles and passes its tests on Linux (Ubuntu 24.04, in CI), but I haven’t played it on a Linux desktop, so I don’t claim Linux support yet.
What I’d do next
- Touch controls for the web build. Phones can run it but have no way to aim; twin virtual sticks would open it up to anyone visiting on mobile.
- Levels from files. The guns, enemies and props already come from Blender; arenas laid out in a level editor (TrenchBroom) would let the content grow without touching C++.
- A guided practice range for learning the parry, finisher and boss tells, building on the existing developer checkpoint select.