Notebook · Games 13 min read

BeastWars, an arena shooter in four and a half days

Low-poly animals, Quake 3 movement and all nine Quake 3 weapons in the browser. How I built BeastWars in 243 commits, and the optimization day that took it from 140 to 700 frames per second.

BeastWars is a browser arena shooter. Low-poly animals fight with all nine Quake 3 weapons, and the movement is a port of Quake 3's own player physics, so strafe-jumping, circle-jumping, rocket-jumping and the 125 fps jump all work the way they did in 1999. There's nothing to install. Play it at beastwars.dagthom.as, host an arena, add a few bots and see how long you last.

Eight bots on Aerowalk, filmed by the game's own camera drone.

Why it exists#

My friends and I played a lot of Quake 3 Arena. Then we got older, and getting everyone into a match became a project of its own. Someone has to find the game, someone has a new PC without it, someone needs the right maps, the patch and the mod, and by the time the last person has everything downloaded and installed, the evening is gone.

So the plan was simple. If Quake was one link in a browser, with no download, no install and no setup, they would have no excuse. I would send the link, they would click it, and we would be strafe-jumping around Aerowalk again by the evening.

I built the whole thing, put it online and sent the link.

They didn't join.

I built it in four and a half days, from 29 September to 4 October 2026, together with Claude Code. Those days were free evenings and a few days off, not working hours. This is how it was put together, and how one day of profiling made it five times faster.

  • 4.5 daysfirst commit to live29 September to 4 October 2026
  • 243commits216 of them co-written with Claude
  • 5×faster in one day140 fps in the morning, 700 by the evening
  • ~54klines of JavaScriptno framework, no bundler

What's in it#

The movement code is a line-by-line port of Quake 3's bg_pmove.c and bg_slidemove.c. That was the first commit, and it came with tests that check the jump heights at 60, 125 and 333 fps against the original. Everything else is built on top of it.

The arenas are real Quake 3 maps. Drop a .pk3 file into the maps/ folder and the server converts it: it keeps the collision brushes and swaps the look for procedural materials, lamps, fog and portals. It also ships five duel maps of its own, generated by a map tool. The clips here are on Aerowalk, a classic CPMA duel map and the one the benchmark below runs on.

On top of that there are free-for-all, team deathmatch, capture the flag, tourney, Rocket Arena, Clan Arena and Freeze Tag, plus the CPMA ProMode ruleset. There are bots from Easy to Nightmare, solo play that runs entirely in a Web Worker with no server, proximity voice chat, player-hosted peer-to-peer rooms, a HUD editor, and a beast editor with 15 species.

All fifteen species in a row
All fifteen species, side by side in the lineup tool.

The beasts are built in code from boxes and a few shapes per species. Their rounded edges come from a fragment shader instead of extra geometry, which saved 35 to 55% of the triangles on every gun and about 30% on every body. You pick a species, a build, ears, a tail and a pattern in the menu, and your beast turns on a pedestal while you do it.

The menu: a custom rabbit with a lizard tail.

The first visit opens with a short story told in the engine itself, with the beasts gathered around a fire before the fighting starts.

The intro, rendered live in the browser.

The stack is deliberately plain: Three.js on WebGL, Node on the server, ws for WebSockets and node-datachannel for WebRTC. There is no framework and no bundler. The server minifies each file once with esbuild and serves it pre-compressed.

Built with agents#

Of the 243 commits, 216 were written with Claude Code. Many features were built in parallel: 25 of the 26 merges come from separate agent worktrees, each working on its own branch (threaded rooms, UDP transport, peer-to-peer hosting, proximity voice, precise hitboxes) while I tested the game and decided what came next.

Commits per day

02040608029 Sep · Commits: 230 Sep · Commits: 511 Oct · Commits: 612 Oct · Commits: 733 Oct · Commits: 424 Oct · Commits: 1429 Sep30 Sep1 Oct2 Oct3 Oct4 Oct
2 October was the optimization day. 4 October is the first 42 minutes after midnight.

The days fell into a rough order. The first day was the physics port and collision. The second added the game modes, the network protocol and the threaded server. The third was polish and tools: the procedural map surfaces, the Movement Lab, a trailer rendered by the engine itself, and an audit of hit registration. The fourth was the optimization day. The fifth fixed freezes, raised the netcode to 62 Hz and moved the game to a server of its own.

Not every experiment survived. Six commits are reverts, and on the last night both portal clouds and fur went in and came out again.

The Movement Lab#

Strafe-jumping is the heart of Quake movement, and almost nobody can explain why it works. So the game has a lab for it: an empty grid where every number in Quake's acceleration function is shown live, frame by frame.

The demo run: every hop lands on the same frame, and the speed keeps climbing.

The panel at the top left is PM_Accelerate with real values. Your speed limit on the ground is 320 units per second (ups). In the air the game only caps the speed along the direction you're pressing. If you turn your view slightly away from where you're flying, that projected speed drops below the cap, the game adds a little acceleration, and your real speed creeps past 320. Do it on every jump and it keeps growing.

The lab draws that as arrows. Orange is where you're moving, blue is where you're pressing, and the green arc is the angle that gains the most speed at your current speed. The dial at the bottom left shows your angle against the ideal one, and the graph at the bottom right shows the last ten seconds of speed.

A five-step tutorial starts with plain running and works up to staying in the green. Press G and the game demonstrates it. When a run ends, a summary scores it.

The optimization day#

By the morning of 2 October the game was fun, and it ran at 140 frames per second on an RTX 5090 at 1080p. That sounds fine until you remember the 125 fps jump. Quake movement rewards a high, steady frame rate, and the headroom on a fast GPU was smaller than it should be.

The first step was to make the measurement repeatable. Quake 3 had timedemo 1, and BeastWars got the same thing: a recorded 60-second match of seven bots on Aerowalk, replayed with a fixed 16.667 ms clock step and a seeded random generator. The same demo produces the same frames every time, so a change in frame rate comes from the code and nothing else.

Timedemo frame rate on 2 October

0 fps200 fps400 fps600 fps800 fpsMorning · fps: 140 fpsMidday · fps: 388 fpsEvening · fps: 700 fpsMorningMiddayEvening
Seven bots, 60 seconds, RTX 5090 at 1080p. The median frame went from 6.4 ms to 2.45 ms to 1.15 ms.

Almost all of the gain came from three things.

Fewer draw calls#

A WebGL frame costs more per draw call than per triangle, and the game was making hundreds of them. Each fix went after one kind of object:

  • Gibs and rockets were merged into single meshes with their colours baked into the vertices.
  • Pickups stopped being separate objects. They spin and bob in the vertex shader, so about 135 pickup draws became a handful.
  • Missiles became an instanced batch. Fifteen rockets in the air went from 30 draws to 2.
  • The glow on a beast with a powerup became a rim term in the body shader instead of a second shell mesh.
  • Parts of a beast that share a colour now share one skinned mesh. A close beast went from 14 skinned draws to 6.
  • Beyond 450 units a gun becomes one flat-coloured mesh, and beyond 800 a whole body becomes one skinned mesh with its textures baked into vertex colours.

Draw calls in the Aerowalk demo

0100200300Start · Draw calls: 294Gibs, rockets · Draw calls: 225Pickups · Draw calls: 191Gib fix · Draw calls: 169Shared parts · Draw calls: 151LOD 1 · Draw calls: 138LOD 2 · Draw calls: 131LOD 3 · Draw calls: 129StartGibs, rocketsPickupsGib fixShared partsLOD 1LOD 2LOD 3
Each point is a measured result from one commit. Smaller commits in between moved the count a little.
Rockets and gibs are merged meshes with their colours baked in.

Particles on the GPU#

Particles used to be moved by the CPU every frame and uploaded again. Now a particle's slot is written once when it spawns, and its whole path (position, gravity, fade) is a closed-form function in the vertex shader. Mist, lava arcs, bubbles and blood all moved over. The CPU never touches a particle again after it spawns.

Less work per frame on the CPU#

Three.js updates every object's matrix every frame. A custom pass skips hidden subtrees, which took the matrix update from 240 to 98 µs. Body parts that never move ride on their joints, and the audio listener, HUD and the feather springs now run at a steady 120 Hz instead of every frame.

The garbage collector got the same treatment. A mega-health effect ran marching cubes every frame even when nobody could see it, producing about 40 MB of garbage per second. Moved to run only when visible, it went from about half a millisecond to 0.03 ms. Per-frame allocations on the menu fell from over 1 MB to 0.7 MB, and on one map from 0.9 to 0.45 MB.

A lightning gun fight on Aerowalk.

Freezes are worse than slow frames#

A steady 700 fps means nothing if the game hangs for a second when someone picks up a powerup. The second half of the optimization work went after freezes, and some of them were long.

The worst was the first visit. Shaders compile the first time they're used, and on Windows the browser translates WebGL shaders through Direct3D's compiler. The world shader included a relief-height function at six call sites, one of them inside a 22-step loop, and the compiler inlined all of them. Compiling that one shader took 19 seconds. Once that was fixed, it compiles in 6.

First visit, before and after the shader fix

  1. Shader wait, before32.5 s
  2. Shader wait, after6 s
  3. Ready to play, before43 s
  4. Ready to play, after16 s

The others had ordinary causes:

  • Picking up a powerup froze the game for 250 to 350 ms. Three.js deep-copies userData when it clones an object, and that copy was turning textures into PNG data URLs. With that fixed, the pickup takes 8 ms.
  • A bot's first weapon switch built the gun from scratch, which took 3 to 17 ms. Guns are now built once and cloned. The worst frame in the benchmark dropped from 30 ms to about 9.
  • Six bots joining at once built six beasts in one frame, which took 174 ms. They're now built one per frame, so the worst frame is 16 ms.
  • The menu used to show a bot fight behind it, which compiled about 40 extra shader programs before you could click anything. It became a warm CSS glow, and the loader now finishes in about half a second.

The bot fight didn't go to waste. Its camera drone circles the bot at the centre of the action, tries a few orbit angles traced out from that bot's head so it always has a view, and slides along walls like a player instead of passing through them. The graphics test in Settings still uses it to stage a match and measure your machine, and it filmed every gameplay clip in this article, in headless Chrome at 60 frames per second.

Eight bots, one drone, no player.

Frames that arrive more than a second late are now reported to the game's analytics, so freezes on other people's machines show up too.

Netcode#

Version 2 of the network protocol sends binary snapshots, quantized and delta-coded against the last snapshot the client confirmed, and only includes players the client could plausibly see. Each snapshot fits in an 1,150-byte datagram.

Download per player, 16-player room

  1. Protocol v157 KB/s
  2. Protocol v2 (binary, delta)10 KB/s
In an 8-player room it is about 4 to 5 KB/s, against 30 to 40 KB/s before. Server memory on the main thread fell from about 390 MB to 13 MB.

WebSockets run over TCP, and TCP holds back every packet behind a lost one. In a game you'd rather drop the old snapshot and use the next. So the server also offers WebRTC data channels, which behave like UDP. Under 4.6% packet loss, the 99th percentile snapshot age was 122 ms over TCP and 33 ms over UDP.

Snapshot age at 4.6% packet loss, 99th percentile

  1. WebSocket (TCP)122 ms
  2. WebRTC data channel (UDP)33 ms

Snapshots go out at 62 Hz, which cut the render delay from 51 to 30 ms and the share of frames that have to guess ahead from 6% to 0.5%. That work also caught a Windows timer bug that had been running the server at 35 ticks a second instead of 62.

Lag compensation rewinds the other players to where you saw them when you fired. Raising the rewind limit from 300 to 500 ms took hits on a strafing target at about 200 ms ping from 46% to 100%.

The drone holds the fight in frame and never passes through a wall.

The server#

Each arena runs on its own worker thread, and maps reach the threads as shared memory, so one busy arena can't slow another down. A full 8-player room ticks at a steady 16 ms and uses about 2% of one CPU core.

The hardware mattered too. On a single-core JavaScript benchmark, the shared server it started on took about 2,500 ms, the new one about 570 ms, and my desktop about 295 ms. With the new server, the arena limit went from 2 + 2 to 20 + 20.

The arena from a spectator's view, with the scoreboard and kill feed
Watching a bot called Nibbles. Fire follows the next player, jump flies free.

What I'd do again#

  • Make the benchmark deterministic before optimizing anything. A recorded demo with a fixed clock turns "it feels faster" into a number you can compare between commits.
  • Count draw calls before triangles. Merging, instancing and moving animation into shaders did more for the frame rate here than simplifying any model.
  • Treat a freeze as a worse bug than a slow frame, and log the ones that happen on other people's machines.
  • Write the tricky parts with tests from day one. The physics port had its tests in the first commit, so every later change could be checked against the original jump heights.
  • Let agents build in parallel, but keep the decisions yourself. Separate worktrees let several large features grow at the same time. Choosing what to keep was still my job.

BeastWars is live at beastwars.dagthom.as. Host an arena, set the bots to Nightmare, and open the Movement Lab first.

  • games
  • three.js
  • performance
  • netcode
  • webgl