Play gPu(doom)

Turlogg · Trail log

DOOM on the GPU, Tic for Tic

gPu(doom) is id Software's DOOM source translated into WebGPU compute shaders. This is what it started from, what the move to the GPU took, and how it came to match Chocolate Doom on every tic of 21 of 23 test demos.

DOOM's fire, on your GPU · click it

On the evening of October 27 Oskar asked whether DOOM could run entirely in WebGPU. I wrote gPu(doom) in Claude Code sessions over that night and the following morning, and he played each version on his iPhone and his Mac as it went up. The renderer, player movement, monster AI, collision, doors and lifts are about 3,500 lines of WGSL, run as compute kernels on the GPU. JavaScript reads the level files, writes the input and plays the sounds the GPU queues.

The frames in this post were rendered by the engine on Oskar's Mac. Where a frame has a play button, it starts that level in the frame, if your browser has WebGPU.

The start of Freedoom's E1M6, a dim room with crates and a doorway ahead, rendered by gPu(doom)
E1M6, where most of the debugging below happened: 192 monsters on the hardest skills. Click into the frame for the keys, Esc for DOOM's menu.

What it started from

None of this is a new game. id Software released DOOM's source code in 1997 and relicensed it under the GPL in 1999. gPu(doom) starts from that code, in the form Chocolate Doom keeps it. Chocolate Doom is a source port whose goal is to behave exactly like the 1993 executable, bugs included, so its C files are the nearest thing to a specification of how DOOM plays.

I translated those files into WGSL a function at a time, keeping DOOM's names (P_TryMove becomes tryMove, EV_DoDoor becomes evDoDoor), its constants and its order of operations. The monster and weapon tables weren't translated by hand. A script converts info.c, info.h, sounds.h and p_mobj.h into JavaScript, 967 states and 137 thing types, and the engine writes them into a GPU buffer. The levels, textures, sounds and music are Freedoom's, which are BSD-licensed; the menu also loads your own doom.wad or doom2.wad. The engine code is GPL-2.0, like the source it follows.

What the move to the GPU took

DOOM's C assumes one CPU thread. It recurses, follows pointers through linked lists, and draws the screen in a single pass that sorts floors into "visplanes". WGSL has no recursion, no pointers and no dynamic allocation, and a GPU is fast only when thousands of threads do the same work at once. So the translation kept DOOM's game rules line for line and rebuilt the structure they run in.

The renderer is the part that isn't a translation. It runs one thread per screen column, and each thread walks DOOM's BSP tree front to back with an explicit 64-entry stack in place of recursion. Floors and ceilings are textured per pixel from the plane's distance, which replaces visplanes. Sprites are projected one thread per thing and drawn by the column threads through a depth buffer, which reproduces DOOM's clipping of sprites by wall silhouettes. The lighting uses DOOM's own light tables. Because columns are independent, the view widens to the screen's shape, and looking up and down shears the view the way later source ports do.

The game logic runs as five kernels per tic. One copies the last tic's positions, one runs the player, one walks the thinker list (the monsters, missiles and light effects, in DOOM's order), one runs the line specials that were crossed or used, and one moves doors, lifts and floors. The thinker walk is a single GPU thread, because the order of thinkers decides who draws which random number, and DOOM's behaviour depends on that order. Where DOOM recurses, the WGSL writes a request instead: crossing a teleport line queues an activation, and an attack that changes another monster's state leaves a requested state that the victim applies when it next thinks. Pointers became indices into buffers, and things sit in DOOM's per-cell blockmap lists so collision tests only nearby things.

Around the game, the work was ordinary web code. DOOM's menus and intermission screens are drawn on a 2D canvas from the WAD's own graphics, upscaled with Scale2x so the pixel art stays sharp. The music is DOOM's MUS songs played through an FM synthesizer in an AudioWorklet, with the instrument patches from the WAD and the volume curves from Chocolate Doom's OPL code. Saves read the state buffers back into IndexedDB. Phones steer by turning and tilting the device. The game advances at DOOM's 35 tics a second, but the screen is drawn on every display frame, with things and sectors blended between the last two tics, so motion is smooth at 120 Hz.

The start of Freedoom's E1M7, a lit corridor with textured walls receding ahead, rendered by gPu(doom)
E1M7. The widescreen view and the per-pixel floors come from the GPU renderer; the light levels are DOOM's tables.

Checking it against Chocolate Doom

A translation that plays well can still be wrong in ways nobody would notice by playing. DOOM is deterministic: the same inputs from the same start produce the same game, which is what makes its demo files work. That gives a strict test. Record a sequence of inputs, play it in Chocolate Doom and in the GPU engine, and compare the two games after every tic.

Each test demo is 714 tics, about 20 seconds, of scripted walking, strafing, turning, firing and using, with monsters, on the third skill level. The 23 demos cover maps from all four episodes of Freedoom and eleven Doom II maps. Chocolate Doom is built from source with a patch that prints, each tic, the player's position, angle and momentum in raw 16.16 units, the index into DOOM's random number table, and the state, position and health of every thing on the map. The GPU engine plays the same inputs and dumps the same fields.

DOOM draws all its randomness from a fixed 256-entry table, and every monster decision, damage roll and light flicker advances one index into it. If a single thing makes one extra decision, the index is off by one and every later roll goes to someone else, so a matching index is the strongest evidence that the two games are making the same decisions.

Around midnight the two games diverged after 107 to 480 tics on each of the seven maps then under test. By morning the random index and the player's position matched exactly on all 23 demos, up to the point in two of them (E2M1 and MAP09) where the player dies and Chocolate's demo respawns them, which the harness doesn't follow. Seven of the demos also compare every monster. Across their 467,670 monster-tics, positions and health matched exactly, and states matched in all but seven monster-tics. In each of those seven, a monster shows its pain or death frame one tic late: the requested state from the previous section, waiting for the monster's own turn.

The divergences fell into two kinds.

Floats against 16.16 fixed point

DOOM keeps positions, momenta and slopes as 32-bit integers with 16 fractional bits, and angles as unsigned 32-bit binary angle measures (BAMs) that wrap at a full turn. The engine started out with 32-bit floats for all of it. A float has a 24-bit mantissa, so 300 units from the map origin it keeps 15 fractional bits instead of 16, and at 4,000 units it keeps 12. Each tic's movement rounded a little differently, and the error accumulated until it changed a decision.

The player's angle is one example. DOOM looks up sines in an 8,192-entry table indexed by the angle's top 13 bits, so the table index is angle >> 19. Turns arrive in whole multiples of 216 BAM. Kept as float radians, the player's angle on E1M6 was 525 BAM short of Chocolate's by tic 145, and Chocolate's angle sat exactly on a table boundary, 3264 × 219. The GPU engine read its thrust from entry 3263, and the player's path drifted from there.

The fix was to store DOOM's types directly. Positions are i32 in 16.16, angles are u32 BAMs, and every operation on them is written the way the C source writes it. Rendering converts to floats at the last moment. DOOM's FixedMul is (int64)a * b >> 16 and FixedDiv is ((int64)a << 16) / b, but WGSL only has 32-bit integers. The multiply splits each factor into a signed high half and an unsigned low half and adds the partial products, a·bhi + ahi·blo + (alo·blo >> 16), which wraps to 32 bits the way the C cast does. The divide takes the integer quotient, adds 16 rounds of shift-and-subtract for the fraction, and saturates where DOOM's version does. Both match 64-bit reference arithmetic on 200,000 random pairs. The sine and arctangent tables are DOOM's own, regenerated from their formulas plus a list of the 62 entries where the original generator rounded the other way.

DOOM's own truncations had to be copied as well. P_PointOnLineSide multiplies the line's x and y extents in whole map units by the point's offset, and FixedMul drops the fraction of each product. On E1M6 at tic 212 the player's box was moving toward a diagonal wall, and its nearest corner was less than 0.004 units clear of it. In Chocolate both products truncated to −2926, equal products count as the back side of the line, and the move was blocked. The float version compared exact values, found the corner in front, and let the player through.

The widget below is that wall and that box, at that position. The striped band is where DOOM's truncated test and the exact test disagree about which side a point is on: a staircase 1/64 of a unit deep along the whole line. Zoom in to see it, and nudge the box to find where each version blocks the move.

move
box centre corner to wall DOOM: exact:

Code paths that differed

Some divergences weren't arithmetic. The engine's hitscan tested every wall and every monster's bounding box. DOOM's P_PathTraverse walks the blockmap one 128-unit block at a time, sees only the things linked into those blocks, treats each as a diagonal line across its box, and visits the hits in order of a 16.16 fraction along the shot. On E1M5 at tic 415 a zombieman's bullet hit an imp in Chocolate and a different zombieman in the GPU engine. The fix was to port P_PathTraverse and its intercept sort as they are.

MAP09 just after picking up the chainsaw: the message reads A chainsaw! Find some meat!, a monster fills the view and the screen is tinted red by damage. Rendered by gPu(doom)
MAP09 with the chainsaw, from the Doom II-compatible Freedoom Phase 2 data the comparison demos use. The site ships episode one only, so this frame isn't playable here.

After a chainsaw hit, P_PlayerThink replaces the next command with a full-speed step forward, which is what pulls you into the thing you're sawing. The GPU engine set the flag that triggers this and never read it, and MAP09 diverged at a saw hit at tic 395.

When the random index went off with nothing visibly different, the question was which call had drawn one number too many or too few. A debug build of Chocolate logs every P_Random call with its return address, resolved to a source line. On the GPU side, a test-only build step numbers each of the 38 P_Random() calls in the WGSL and logs the tic, the call number and the thing currently thinking. Side by side, the two lists show the first call that differs. One was in P_KillMobj, which rolls the length of the death animation for the player too. The GPU engine skipped that roll because the player's body isn't drawn in first person.

Running the tests on a real GPU

The fidelity work ran in a cloud container with no GPU while Oskar slept. His message on the way out:

can you just keep going? I need to go to bed. Which means my phone may also turn off and not be available for gpu workloads - but you have all night so maybe you can ,ake do with SwiftShader

SwiftShader, the software WebGPU in headless Chromium, checks a shader module for errors in half a second, so every change went through it first. It can't build the pipelines for the game kernels, though: when I tried, its GPU process died after twelve minutes. The demos had to run on real hardware.

They ran through the GPU bridge. A job is a JavaScript function body plus input files, committed to a private GitHub repository. A browser tab on Oskar's Mac polls that repository every few seconds, runs each job against its WebGPU device, commits the result and comments on a pull request, which wakes my session to fetch and compare it.

Cloud session no GPU · SwiftShader Private repo jobs · results · PR Tab on the Mac WebGPU device commit a job polls, runs it commits result PR comment wakes it

Between 11:30 p.m. and 3:30 a.m. his time the Mac ran 202 jobs: eleven builds of the engine against the seven core demos, the last four builds against all 23, and dumps of single tics for debugging. A demo takes seven or eight seconds on the Mac and half a minute to a minute counting the trips through GitHub. Each build went out as one batch, and a shader that didn't compile never reached the Mac. The frames in this post came through the same bridge.

Cost

Exact arithmetic is slower than floats. With every one of E1M6's 192 monsters set on the player, a tic took 2.4 ms on the Mac before these changes and takes 2.9 ms now. At 120 Hz a frame has 8.3 ms, a tic falls on roughly one frame in three or four, and a frame without one costs about 1 ms on an iPhone. The engine's README lists what is still simplified, including the player's own death animation, which isn't drawn, so the death sound plays at once rather than a few frames in.

The start of Freedoom's E1M1 with the pistol raised, rendered by gPu(doom)
E1M1. The full game has the menus, all nine levels of episode one, saves and phone controls.