GitHub - mariuz/firebird-doom: DOOM simulated and rendered inside Firebird SQL, running in the browser via Firebird WASM

GitHub

75 min read Original article ↗

DOOM, simulated and rendered inside the Firebird SQL database, running entirely in your browser on Firebird 6 compiled to WebAssembly.

▶ Play: https://mariuz.github.io/firebird-doom/

The series went on to true 3D: Firebird Quake, Firebird Quake 2 and Firebird Quake III Arena (play), with Bézier patches, MD3 player models and deathmatch bots that think in SQL.

Every game tic is a PSQL procedure call. Every frame is a SELECT. JavaScript only reads the keyboard and paints the rows Firebird returns.

E1M1 rendered by Firebird: the opening room, a dead zombieman and the status bar

Two monsters, sprites picked and projected by FRAME_SPRITES E1M2: pillars, steps and a lit doorway
Phase 2 MAP11: Commander Keen hanging in his alcove Phase 2 MAP30: the Icon of Sin's brain
After MAP30: Freedoom's story text typed over the RROCK17 flat The cast call: each monster on BOSSBACK under its name
The intermission after E1M2: kills, items, secrets, time and par (example numbers) The main menu over the title screen, with the skull cursor
A spectre in E1M2: a shimmer of fuzz, darker than what's behind it The same view, invulnerable: COLORMAP 32, the inverse greys

These screenshots come from npm run screenshots, which runs the same SQL and rasteriser as the page, headless in Node.

This project follows CedarDB's SQL DOOM (the original WAD, rendered by a database) and DOOMQL (a DOOM-like game in pure SQL), and DuckDB-DOOM (a SQL raycaster in the browser through DuckDB-WASM). This one is graphical, plays real DOOM maps, and runs Firebird in the tab.

How it works

 keyboard ─▶ SELECT * FROM doom_tic(tics, fwd, side, turn, fire, use, weapon, run)   ← game logic
             SELECT * FROM frame_walls      ← which wall slice is visible in which column
             SELECT * FROM frame_sprites    ← which sprite frame, where, how bright
             SELECT * FROM frame_sectors    ← live floor/ceiling heights and light
         ─▶ JS looks up texels + colormaps ─▶ <canvas>
DOOM source Firebird
WAD lumps VERTEXES LINEDEFS SIDEDEFS SECTORS SEGS SSECTORS NODES THINGS tables of the same names (sql/schema.sql)
info.c mobjinfo THING_TYPES rows (src/thinginfo.js)
R_PointInSubsector SECTOR_AT() walks the BSP; INIT_MAP locates every thing in one recursive CTE
BLOCKMAP LINE_BLOCKS, a 128×128 grid built by INIT_MAP
P_CheckPosition, P_TryMove CHECK_POSITION, plus wall sliding in PLAYER_THINK
P_CheckSight CHECK_SIGHT() walks the blockmap cells along the sight line
P_UseLines, P_CrossSpecialLine, EV_DoDoor/Plat/Floor, stairs, exits ACTIVATE_LINE, MOVERS, MOVERS_THINK
G_DoCompleted: which map comes next, secret exits included src/progress.js
EV_DoCeiling crushers, EV_CeilingCrushStop, raiseFloorCrush, P_ChangeSector the crush mover kind, CRUSH_THINGS
P_SpawnMissile / P_SpawnPlayerMissile aiming, P_AimLineAttack, P_ZMovement for missiles MONSTER_MISSILE, FIRE_MISSILE, AIM_SLOPE; 3D flight in MONSTERS_THINK
A_Look / A_Chase / attacks, pain, death, barrels MONSTERS_THINK
P_DamageMobj retargeting, PIT_CheckThing species rule (infighting) DAMAGE_THING(..., src), THINGS.TARGET_ID / THRESHOLD, HURT_TARGET
P_NoiseAlert, P_RecursiveSound (gunfire wakes monsters) NOISE_ALERT flooding SOUND_LINKS, SECTORS.SOUND_HEARD
A_Tracer, A_SkelFist (revenant), A_FatAttack1/2/3 (mancubus), A_VileTarget / A_Fire / A_VileAttack / A_VileChase (arch-vile), A_PainShootSkull / A_PainDie MONSTERS_THINK (the melee, heal and raise states, the flame kind), MONSTER_MISSILE, PAIN_SHOOT_SKULL
A_BossDeath, A_KeenDie BOSS_DEATH, KEEN_DIE
A_BrainSpit, A_SpawnFly, A_BrainScream (the Icon of Sin) the shooter, cube and brain kinds in MONSTERS_THINK
MF_SPAWNCEILING (hanging bodies, Commander Keen) THING_TYPES.HANG, placed in INIT_MAP
P_LineAttack, P_BulletSlope (pistol, shotguns, chaingun, fist, chainsaw) HITSCAN (in 3D, with a slope), AIM_SLOPE
A_Saw, A_FireShotgun2 (chainsaw, super shotgun) HITSCAN at melee range / 20 pellets; reload sounds timed in PLAYER_THINK
P_SpawnPlayerMissile, P_RadiusAttack, A_BFGSpray (rocket launcher, plasma gun, BFG) FIRE_MISSILE, RADIUS_ATTACK, BFG_SPRAY; projectiles fly in MONSTERS_THINK
P_TouchSpecialThing pickups in PLAYER_THINK
light flashes, strobes, glows LIGHTS_THINK
R_RenderBSPNode, R_CheckBBox, R_ClipSolidWallSegment (solidsegs) RENDER_SLICES_BSP (sql/render.sql)
r_segs.c clip arrays, markceiling / markfloor RENDER_WALLS / FRAME_WALLS (or FRAME_WALLS_WINDOWED)
r_plane.c visplanes: R_FindPlane, R_CheckPlane, R_MakeSpans, R_MapPlane c_top/c_bot/f_top/f_bot per slice, FRAME_VISPLANES; spans in src/renderer.js
I_SetPalette / I_FinishUpdate: palette indices to colours src/present.js: WebGL palette shader, Canvas 2D fallback
r_things.c, R_DrawFuzzColumn (MF_SHADOW) RENDER_SPRITES / FRAME_SPRITES (its fuzz column); fuzz in src/renderer.js
S_StartSound (+ sfxinfo sounds per monster) PLAY_SOUND / SECTOR_SOUND → SOUND_EVENTS; played by src/audio.js
I_PlaySong, DMX's OPL driver, the YM3812 src/music.js (MUS + MIDI), src/dmx.js (the driver), src/opl.js (the chip), src/opl-worklet.js

The renderer

There are two wall renderers. You can switch between them under Renderer in the page's settings; the choice is stored in VIEWCFG.USE_BSP.

BSP front to back with solidsegs (the default). RENDER_SLICES_BSP walks NODES from the root like R_RenderBSPNode, nearer child first. Before entering the farther child it projects that child's bounding box onto the screen (R_CheckBBox) and skips the whole subtree if every column it covers is already hidden. In each subsector it projects the segs that face the viewer (R_AddLine). When a seg is solid (one-sided, or a closed door) its columns are marked as covered (R_ClipSolidWallSegment). PSQL has no arrays, so DOOM's solidsegs list is a VARCHAR with one character per screen column, and the traversal stack is a string too. The walk stops when no '0' is left in the coverage string. On Freedoom's 36 maps this is about 3× faster than brute force, and it finds exactly the same visible walls. CI checks that.

Brute force. RENDER_SLICES projects every linedef in front of the camera, whether or not anything hides it.

Both generators transform into view space, clip to the near plane, and intersect each screen column's ray with the seg. That gives an exact depth, a texture column, and the vertical opening the seg leaves (open_top/open_bot) for whatever is behind it. RENDER_WALLS then plays the part of DOOM's ceilingclip[]/floorclip[] arrays: ORDER BY col, depth sorts each column front to back, and the clip window is carried down the column. The same idea, stated declaratively, is the FRAME_WALLS_WINDOWED view:

SELECT *
  FROM (SELECT s.*,
               COALESCE(MAX(open_top) OVER (PARTITION BY col ORDER BY depth
                        ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) clip_top,
               COALESCE(MIN(open_bot) OVER (PARTITION BY col ORDER BY depth
                        ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 1e9) clip_bot
          FROM render_slices s)
 WHERE clip_top < clip_bot

The smoke test checks that FRAME_WALLS_WINDOWED matches FRAME_WALLS slice for slice. The game uses the procedural clip because Firebird's window sort costs about twice as much.

FRAME_WALLS sends the browser five values a visible slice: the column, the depth, the texture column, the line and the side seen. Handing rows from Firebird to JavaScript costs per value, about 0.4 µs each, and everything else RENDER_WALLS knows about a slice is arithmetic on those five and the sector heights the browser already has. That covers the opening, the clip window (the openings in front of it, down the column) and the rows of ceiling and floor it uncovers. expandWalls and visplaneMarks in renderer.js redo that arithmetic in the same order. The smoke test holds them to RENDER_WALLS value for value, and a check over every map of both WADs, 1,020 views and 964,338 slices, found no difference. Sending five values instead of fourteen took FRAME_WALLS from about 25.6 to 21.6 ms (E1M1–E1M3 and E2M2, 32 views). Firebird still decides what's visible: which slices, how deep, and which texture column.

FRAME_WALLS depends only on the view and the sectors' heights; the rest it reads is the map's own. So while you stand still and no door or lift moves, the page draws its last answer again instead of asking. It fetches the sector heights first, and compares them, the view and its size with what the last answer was for.

Two Firebird-specific performance lessons:

  • Derived tables are inlined. Each reference to a computed CTE column re-evaluates its whole expression tree, so a five-deep chain of projections took seconds. Generator procedures compute each value once into a variable. A PSQL loop runs at about 0.2 µs per statement in WASM.
  • Pin the join order. Joining a computed range to SCREEN_COLS took 65 s as an inner join, because the optimizer drove from the wrong side. With CROSS JOIN LATERAL or LEFT JOIN it took 0.2 s.

Floors and ceilings: visplanes

DOOM doesn't texture floors column by column. While drawing walls it records, for each column, which rows of the front sector's ceiling and floor the wall leaves visible (markceiling and markfloor in R_StoreWallRange). It collects those rows into visplanes: one per distinct height, flat and light level, with at most one span per column. Then it draws each visplane as horizontal spans, because every pixel in a row of a flat surface is the same distance away.

Here RENDER_WALLS returns those rows with every slice: c_top/c_bot for the ceiling and f_top/f_bot for the floor, computed from the same clip window it uses for the walls. The browser does R_FindPlane/R_CheckPlane. It groups the spans by (height, flat, light), starts a new plane when a column is already taken, and merges all sky into one plane. R_MakeSpans then sweeps each plane left to right, turning column spans into row spans. R_MapPlane draws each row span with one distance and light lookup, stepping the texture coordinates linearly. SELECT * FROM frame_visplanes shows the current frame's planes in the SQL console, and the stats line under the view counts them. Across Freedoom's maps the busiest frame needs 42, comfortably under vanilla DOOM's MAXVISPLANES of 128.

The status bar face

The face is ST_updateFaceWidget's, tic by tic (FaceWidget in src/hud.js). It has five pain levels by health, and in order of priority it shows:

  1. dead;
  2. the evil grin for two seconds after picking up a weapon you didn't have;
  3. when something hurts you, a look towards it (left, right, or the rampage face if it's within 45° ahead);
  4. the rampage face when the world hurts you (slime, a crusher);
  5. the rampage face after two seconds of holding the trigger;
  6. the god face, with IDDQD or invulnerability;
  7. otherwise, a glance straight, left or right every half second.

Vanilla's test for the "ouch" face is backwards (health has to rise by more than 20 while you're hurt), so it hardly ever shows, and the port keeps that. DAMAGE_PLAYER records who hurt you (PLAYER.ATTACKER_ID, empty for the world), and DOOM_TIC returns the angle to them. Dead, the view turns to face your killer 5° a tic, and the red stays until it does (P_DeathThink). npm run test:statusbar tests the face's rules headless, and the attacker and the death camera in Firebird.

Light diminishing

How dark a pixel gets is DOOM's own integer arithmetic (src/renderer.js): R_InitLightTables' zlight and R_ExecuteSetViewSize' scalelight, 16 light levels by 32 colormaps. Walls and masked middles take scalelight by their projected scale (R_RenderSegLoop), sprites likewise (R_ProjectSprite), and flats take zlight by distance in 16-unit steps (R_MapPlane). Walls running due east–west or north–south get the fake contrast, a light level darker or lighter. Vanilla's low-detail quirk is kept: halving the view's columns halves a wall's scale but not the table, so in low detail walls come out darker at a distance, while sprites (whose shift accounts for detail) and flats don't change. The muzzle flash's extralight lifts every light level by one or two steps while it shows (A_Light1/A_Light2). npm run test:light re-does DOOM's arithmetic in 16.16 fixed point and checks every table entry, plus walls, sprites and flats at thousands of light and distance samples in both details.

Presenting: palette indices, a WebGL shader, or Canvas 2D

Like DOOM, the rasteriser works in palette indices. The 3D view and the 320×200 screen hold one byte per pixel, already lit through COLORMAP: light levels, the invulnerability greys and spectre fuzz. Which of the 14 PLAYPAL palettes colours them (normal, pain red, pickup gold, radiation-suit green) is decided once per frame, when the screen is presented (I_SetPalette, src/present.js):

  • WebGL (palette shader): the screen is uploaded as a 320×200 LUMINANCE texture. A fragment shader looks each index up in a 256×14 texture holding every palette, drawing at the canvas's real display size.
  • Canvas 2D: a palette loop into an ImageData and putImageData. It's used when chosen, or when WebGL can't be had.

Display in the settings picks one. A canvas keeps the kind of context it first gave out, so switching swaps in a fresh canvas element. Smooth upscaling blends neighbouring pixels. With WebGL that's bilinear filtering inside the shader on the colours, never on the indices, since blending palette numbers would give nonsense colours. With Canvas 2D it's the browser's own image smoothing. Off, every pixel stays a crisp 4:3 block. The automap draws into the screen in DOOM's own palette colours (am_map.c's REDS, BROWNS, YELLOWS, GRAYS, GREENS, WHITE), over the view darkened through COLORMAP 24. Screenshots and tests read the screen through toRGBA.

Gamma correction

F11 steps through DOOM's five gamma levels, off and 1–4, saying which (I_SetPalette's usegamma), and the level is remembered with the other settings. The tables are v_video.c's gammatable: each level lifts the dark end of every palette entry more than the last, so Freedoom's dimmer maps can be seen into. It's applied where the palette is (src/present.js): the presenter's palettes go through the table, while the renderer's own (what toRGBA and the tests' pictures use) stay as the WAD has them.

Spectres: fuzz

The spectre (MF_SHADOW, the shadow flag in THING_TYPES) comes out of FRAME_SPRITES with fuzz = 1, and the rasteriser draws it with R_DrawFuzzColumn. None of the sprite's own colours reach the screen. Each opaque pixel copies the pixel one row above or below it, chosen by DOOM's 50-entry fuzzoffset table, and darkens it through COLORMAP 6. The table position carries from pixel to pixel and from frame to frame, so the outline ripples as you watch. The screen holds palette indices, as DOOM's did, so darkening is a single COLORMAP lookup. In a dim room a spectre is as hard to see as in DOOM.

The partial invisibility sphere uses the same fuzz. Picking it up gives 60 seconds (INVISTICS, 2100 tics in PLAYER.INVIS_TICS, cleared at the end of a level). Your weapon is drawn as fuzz, and it flickers back in the last four seconds. Monsters aim the way they do at any MF_SHADOW target, which includes a spectre they are fighting. A_FaceTarget turns up to 45° wide, so a zombie's volley lands only if that error still points at your body, and a lost soul's charge goes astray. P_SpawnMissile sends projectiles up to 22.5° off. Melee attacks still land. At 280 units a zombieman hits about 65% of the time when it can see you, and about 15% when you're partially invisible.

Armour

Armour comes in two types, as in P_GiveArmor. The green armour gives 100 points of type 1, which absorbs a third of each hit. The blue armour gives 200 points of type 2, which absorbs half, and so do the megasphere and IDKFA/IDFA. You only pick armour up if it has more points than you have. An armour bonus adds a point, up to 200, and gives type 1 only if you had no armour, so bonuses on top of blue armour stay blue. When a hit uses up the last points, the type goes with them (P_DamageMobj). The type is PLAYER.ARMOR_TYPE, and saves keep it. A save from before it existed gets type 2 above 100 points and type 1 below. npm run test:physics checks each rule.

Pickups and the tallies

What a zombieman, shotgun guy or chaingunner drops when it dies is marked MF_DROPPED (bit 65536 of THINGS.FLAGS, above the map's flags) and is worth half, as in P_TouchSpecialThing: a dropped clip gives 5 bullets instead of 10 (P_GiveAmmo(…, 0)), a dropped shotgun 4 shells instead of 8, a dropped chaingun 10 bullets instead of 20 (P_GiveWeapon's dropped). Skills 1 and 5 double that afterwards. A weapon you already have is left on the floor when there is no room for its ammo, and a second chainsaw is always left, since it has no ammo to give. The intermission's tallies count only what vanilla counts: kills are the MF_COUNTKILL monsters, so lost souls and barrels aren't kills, and items are the MF_COUNTITEM things, which are the health and armour bonuses, the soulsphere, megasphere, berserk pack, invulnerability, partial invisibility, computer map and light amplification visor (not the radiation suit, weapons, ammo, keys, health or armour). Monsters that an arch-vile raises or the Icon of Sin spawns aren't added to the total, so 100% can be beaten, as in DOOM. DeHackEd's COUNTKILL and COUNTITEM bits move things in and out of the tallies. npm run test:weapons checks each rule.

Each pickup shows its message at the top of the screen, as P_TouchSpecialThing sets player->message. The text is the WAD's: Freedoom's DEHACKED has every one (GOTSHOTGUN is "Got the pump-action shotgun!"), and a PWAD's or a .deh patch's [STRINGS] replace them by name. A WAD with no such strings, like id's, gets plain words of our own ("Shotgun!"), since id's messages stay out of this repository. Keys speak only the first time, as in vanilla, and the medikit's GOTMEDINEED never shows: vanilla checks health below 25 after adding the 25. npm run test:weapons and npm run test:pwad check the messages.

Changing weapons

A weapon key only picks the next weapon (pendingweapon), as in p_pspr.c. The one in your hands finishes its attack, then goes down 6 units a tic (A_Lower) until it's out of sight. The new one comes up just as fast (A_Raise), and it can't fire until it's up: 31 tics, nearly a second, from one to the other. With the trigger empty, P_CheckAmmo makes the best weapon that has ammo the next one, and nothing is fired. That happens when you pull the trigger or when an attack ends. Its order is plasma gun, super shotgun, chaingun, shotgun, pistol, chainsaw, rocket launcher, BFG. Picking up a new weapon brings it up the same way, and so does the berserk pack with the fist. Every level starts with the weapon rising (P_SetupPsprites), and when you die it drops out of sight. The state lives in PLAYER (PENDING_WEAPON, WEAPON_Y, WEAPON_DOWN), and DOOM_TIC returns WEAPON_Y for the HUD to draw the sprite that much lower. npm run test:weapons times it against vanilla's numbers.

Invulnerability

The invulnerability sphere gives 30 seconds (INVULNTICS, 1050 tics in PLAYER.INVULN_TICS). DAMAGE_PLAYER ignores every hit below 1000, so a telefrag (10000) still kills. While it lasts, the view uses a fixed colormap, as DOOM's R_SetupFrame does. COLORMAP 32, the inverse greys, replaces every light level on walls, flats, sprites and your weapon, flickering off in the last four seconds. The sky stays in colour, as in vanilla, and the status bar shows the god face.

Radiation suit and light amplification goggles

The radiation suit (pw_ironfeet, 2100 tics in PLAYER.IRON_TICS) keeps out nukage and slime (sector specials 7 and 5). The worst floors (4 and 16) still get through 5 times in 256, and E1M8's exit floor (11) hurts regardless, as in P_PlayerInSpecialSector. While you wear it, the screen takes palette 13 (RADIATIONPAL), the green tint, unless a pain or pickup flash is showing (ST_doPaletteStuff). The light amplification goggles (pw_infrared, 4200 tics in PLAYER.INFRA_TICS) set fixed colormap 1, nearly full bright everywhere. Invulnerability's inverse greys win when both are on. Both effects flicker off in their last four seconds.

Berserk

The berserk pack raises your health to 100 (P_GiveBody), puts the fist in your hand and sets pw_strength. PLAYER.STRENGTH_TICS counts up from 1 and lasts until the level ends. While it does, A_Punch multiplies the fist's 2d10 by ten. The screen flushes red and fades over 768 tics: ST_doPaletteStuff treats 12 - (strength >> 6) as pain whenever it's stronger than any real pain flash.

The automap and the computer area map

The automap (Tab) draws only the lines you've seen, as DOOM's does. Every line id that FRAME_WALLS returns counts as seen (ML_MAPPED) for the rest of the level, and lines the map pre-marks with flag 256 count too. Seen lines get AM_drawWalls' colours: red for walls, brown for floor steps, yellow for ceiling steps, dark red for teleporters. Secret lines pass for plain walls and ML_DONTDRAW lines never show. The computer area map sets pw_allmap (PLAYER.ALLMAP) for the level, and every line you haven't seen yet then shows in grey. As with P_GivePower, you can only carry one, so a second one stays on the floor. The colour rules live in src/automap.js and are tested headless.

Typing IDDT with the automap open cycles DOOM's am_cheating, as AM_Responder does. Once shows every line, hidden (ML_DONTDRAW) and unseen ones included, with flat two-sided openings in grey (TSWALLCOLORS). Twice also draws every thing in the level as a green triangle facing its way (AM_drawThings), read live from THINGS. A third time turns it off. Like DOOM, the cheat prints no message and isn't stored in the database.

In a netgame the arrows are AM_drawPlayers's. In co-op everyone's arrow is drawn, each in their colour: green, grey, brown and red, the same order as their sprites. A player with the invisibility is drawn near black (palette entry 246). In deathmatch you see only your own arrow, and IDDT does nothing there (AM_Responder refuses it with deathmatch set).

The automap's keys are AM_Responder's:

  • = and - zoom in and out while held, 2% a tic. The limits are the whole level and a player-sized view. The map opens a little closer than the whole level, as AM_LevelInit does.
  • 0 shows the whole level, centred, and pressing it again puts the view back.
  • F turns follow mode off and on. Off, the arrow keys pan the map, 4 pixels a tic and kept within the level, instead of moving you.
  • G draws the grid, the BLOCKMAP's 128-unit cells from its origin.
  • M marks the spot in the middle of the view with a number (AMMNUM0–9), ten at most, the oldest replaced. C clears the marks.

While the map is open those keys are the map's: F doesn't fire and M doesn't turn the sound off, but cheats still read every key, as ST_Responder comes first. The messages use the WAD's DEHACKED wording (AMSTR_*), falling back to Freedoom's. The view's state is AutomapView, tested headless by npm run test:automap, and it stays out of the database: a level starts with fresh marks.

Sector specials

Every sector type DOOM uses is handled, as in P_SpawnSpecials and P_PlayerInSpecialSector:

  • Lights (LIGHTS_THINK): 1 flickers at random, 2 and 4 strobe fast, 3 slowly, 12 and 13 strobe in sync, 8 glows, and 17 flickers like fire. A strobe dims to the darkest neighbour, or to black if no neighbour is darker.
  • Floors that hurt (every 32 tics, while you stand on them): 7 does 5 damage, 5 does 10, and 4 and 16 do 20. The radiation suit keeps them out, except that 4 and 16 get through 5 times in 256.
  • 9, a secret: counted, with "A secret is revealed!", the first time you step in.
  • 10 (P_SpawnDoorCloseIn30): the door, open when the level starts, closes for good after 30 seconds. 14 (P_SpawnDoorRaiseIn5Mins): the door, shut, opens after five minutes and then works as a normal door. Both are MOVERS rows queued at map load, a waiting door being DIR 0 and a door waiting to rise DIR 2, as in T_VerticalDoor.
  • 11, E1M8's exit floor: it switches IDDQD off, does 20 damage every 32 tics whatever you wear, and once that leaves you at 10 health or less it ends the level (G_ExitLevel), even if the hit killed you, as in DOOM.

npm run test:physics runs each kind of special.

Linedef specials

ACTIVATE_LINE maps every linedef special the player can trigger to its action, as p_spec.c, p_switch.c and the action tables do: walk-over (W1/WR), switches (S1/SR), shootable (G1/GR) and manual doors. Each acts on the sectors with the line's tag, and MOVERS rows do the moving. Besides the common doors, lifts, floors, crushers, stairs, teleporters and exits, the port has:

  • lights: 12/80 to the brightest neighbour, 13/81/138 to 255, 35/79/139 to 35, 104 to the darkest neighbour, 17 starts a slow strobe
  • doors: 16/76 close for thirty seconds, then open again for good (close30ThenOpen)
  • lifts: 53/87 go up and down for ever (perpetualRaise), 54/89 stop them where they are, and 53/87 start them again
  • raise and change: the floor takes the flat of the line's front sector, then rises at half speed to the next floor up (22/95/20/68/47, which also clear the sector's special), or by 24 (15/66/59/93) or 32 (14/67)
  • floors: 37/84 lower to the lowest neighbour and take its flat and special on arrival, 30/96 rise by the shortest lower texture around, 130/129/131/132 rise to the next floor at four times the speed, 140 rises by 512, and shooting 24 raises it to the lowest ceiling
  • ceilings: 40 rises to the highest ceiling around, 41/43 lower to the floor without crushing, 72 is a repeatable 44
  • stairs: 100/127 build them in steps of 16, four times as fast
  • the donut (9): the ring around the sector rises to the floor beyond it and takes that flat, while the hole sinks to the same height
  • scrolling walls (48): the front side's texture moves one unit a tic (P_UpdateSpecials). It's a function of the tic, like animated textures, so the renderer works it out.

Monsters set lines off too, as in P_CrossSpecialLine and P_UseSpecialLine. Walking over a line, a monster can only use teleporters (39, 97, and its own 125/126, which ignore the player), door 4 and lifts 10/88. Bumping into a closed door 1 (not one flagged secret), it opens it and waits while it rises, and it never closes a door. The keyed doors are the player's alone. MONSTER_CROSS checks the lines each step crossed, and P_MOVE tries the door lines a blocked step touched (vanilla's spechit).

A teleport (TELEPORT_THING, EV_Teleport) only works from the front of the line and never takes missiles. It leaves teleport fog and a DSTELEPT at both ends, turns you the destination's way and stops you dead, and the player can't move for 18 tics (reactiontime). Arriving, the player telefrags any monster, barrel or Keen standing on the spot (10000 damage, PIT_StompThing). A monster can't teleport onto something; only on MAP30 do monsters telefrag too.

Moving floors grind (DSSTNMOV) every 8 tics and stop with DSPSTOP, as in T_MoveFloor. npm run test:physics triggers each kind on a test sector and runs it to the end. The renderer test draws a scrolling wall at tic 37 and checks it against the same wall with its offset moved by 37.

Level order

When you leave a level, src/progress.js picks the next one, as G_DoCompleted does. On DOOM I, a secret exit leads to E?M9, and E?M9 returns to the map after the one with the secret exit. E?M8 ends the episode, and the game, with its ending below. On DOOM II, MAP15's secret exit leads to MAP31 and MAP31's to MAP32, and the normal exits of both secret levels return to MAP16. A secret exit on any other map counts as a normal one. MAP30 ends the game with the finale below. Only a WAD without the finale's pictures goes back to MAP01.

The screen melt

Whenever the screen changes from one kind to another (the title, a level, the intermission, an ending), it melts, as D_Display does with f_wipe.c. Every level load melts too, including loading a save and starting over after dying, because G_DoLoadLevel forces it. The old screen is kept (wipe_StartScreen), the new one drawn (wipe_EndScreen), and the old one slides down in columns two pixels wide. Each column starts up to 15 tics late, within a tic of its neighbour (wipe_initMelt), then drops 1, 2, 3… pixels a tic, and 8 a tic after the first 16 (wipe_doMelt). The whole melt takes about 40 tics, a little over a second. Like vanilla's wipe loop, it holds everything else up while it runs: the game doesn't tick, and keys pressed meanwhile are dropped. Vanilla runs the game's missed tics afterwards, but here the game simply waits. It's done in palette indices in src/wipe.js, and npm run test:intermission checks the start offsets, the speeds and the end.

The intermission

Between levels comes wi_stuff.c's screen (src/intermission.js), to D_INTER (D_DM2INT on DOOM II). It shows the level's name patch with "Finished" under it. Then kills, items and secrets count up as percentages, two points a tic, with a pistol shot every four tics and a barrel explosion as each one settles. The time and par count up three seconds a tic. Pars come from vanilla's tables, and episode 4 has none. Fire or use jumps to the final numbers, and the next press moves on (WI_checkForAccelerate; only a new press counts, so the button that hit the exit switch doesn't). DOOM I then shows the episode map: a splat on every level done, the secret level's once you've been there, and a blinking "you are here" on the next, under "Entering" and the next level's name. The episode maps come alive as well (WI_updateAnimatedBack, vanilla's anim_t tables). Episodes 1 and 3 cycle their little animations, three frames 11 tics apart from a random start. On episode 2, the part of the map you're entering lights up and stays lit, restarting when "Entering" comes up. Freedoom's episode pictures aren't maps, so it ships the splat, pointer and animation patches as empty 1×1 placeholders, and only "Entering" shows. Load id's doom.wad and they all appear. DOOM II says "Entering" for ten tics and moves on. It skips this after MAP30, and on the way into MAP31, whose name stays a surprise, both where vanilla's wbs->next is 30. The secret levels get their own names (CWILV30, CWILV31) and pars (2:00 and 0:30), and their exits back to MAP16 announce it. Then comes any text screen, then the next level. As in vanilla, E?M8 skips the stats and goes straight to the episode's ending. npm run test:intermission checks the counting, the sounds, the skipping and the par table.

DOOM II's text screens and finale

Finishing MAP06, MAP11 or MAP20 shows the story so far, as G_WorldDone does: C1TEXT over SLIME16, C2TEXT over RROCK14 and C3TEXT over RROCK07, typed out like the ending's text below to D_READ_M. After 50 tics, fire or use goes on to the next map with your inventory intact. The two secret levels get theirs on the way in: leaving MAP15 by its secret exit shows C5TEXT over RROCK13 before MAP31, and leaving MAP31 by its secret exit shows C6TEXT over RROCK19 before MAP32. Their normal exits show nothing. A WAD without the words (DOOM II keeps them in its executable, not its WAD) just goes straight on.

DOOM I's episodes end the way F_StartFinale ends them. After E?M8 the episode's text types itself out to D_VICTOR, and it can't be skipped. The flats are FLOOR4_8, SFLR6_1, MFLR8_4 and MFLR8_3 for the four episodes. 250 tics after the last character (TEXTWAIT), the episode's art screen follows (F_Drawer):

  • E1: CREDIT on a four-episode WAD like Freedoom's, HELP2 otherwise.
  • E2: VICTORY2.
  • E3: the bunny scroll (F_BunnyScroll), to D_BUNNY. PFUB2 slides off to reveal PFUB1, then "THE END" stamps in letter by letter (END0–END6), each with a pistol shot.
  • E4: ENDPIC.

That's the end of the game, as in DOOM: the picture stays up (the bunny's "THE END" too) and no key moves on. Esc brings up the menu for a new game or another episode. Saving isn't possible there, as in DOOM.

Finishing MAP30 starts DOOM II's ending (src/finale.js, after f_finale.c). First the story text types itself out a character every three tics over the tiled RROCK17 flat, to D_READ_M (F_TextWrite). After 50 tics, fire or use moves on. Then the cast call (F_StartCast) plays to D_EVIL. Each monster in turn walks on the BOSSBACK backdrop under its name. It attacks every twelve frames, and the revenant alternates punch and missile. Press a key and it dies, playing its death sound and frames, then the next one comes on (F_CastResponder, F_CastTicker). The cast ends with the player and starts over, as in DOOM. Choosing a map starts a new game. The words come from the WAD's DEHACKED lump: Freedoom ships its own story text (C4TEXT) and cast names (CC_*), so none of id's text is reproduced. Sprites whose front view hides in the mirrored half of a lump name (SKELA1D1 holds frame D flipped) are found and drawn flipped. npm run test:finale checks all of this without a screen.

PWADs and patches

PWAD under the view adds one or more PWADs on top of the main WAD, as -file does (src/wad.js). Like W_AddFile, their lumps go into one directory after the main WAD's, and a name finds the last one. So a PWAD's maps, graphics, sounds, music and texture lists replace the main WAD's of the same name. Flats and sprites between their markers are gathered from every file, later ones winning, as Chocolate Doom's -merge and Boom do; vanilla needed the PWAD merged in with DeuTex for those. Every file's DEHACKED lump is applied in order, the main WAD's first. Patch loads a DeHackEd file on top of all of them, as -deh does. With a PWAD loaded, the game starts on the PWAD's first map (vanilla would need -warp). The WAD label shows what's loaded, and Clear goes back to the main WAD alone. Picking another main WAD drops the PWADs and the patch. The browser remembers them, though: they're kept in IndexedDB with the main WAD they were loaded over, so a reload (or picking that WAD again) puts them back, until Clear. Files that no longer load are forgotten, and the page says so. Saves and demos are kept per combination. npm run test:pwad and the browser test check the remembering. npm run test:pwad builds a PWAD with a map, a flat, a sprite, music and a patch, and loads it over Freedoom.

DeHackEd patches

A WAD's DEHACKED lump is applied when the WAD loads (src/dehacked.js, after Chocolate Doom's deh_*.c):

  • Thing blocks change a monster's, item's or missile's hit points, speed, size, pain chance, mass, missile damage, sounds, map number (ID #), and the bits the port models (solid, floating, shadow, hanging from the ceiling). The patched list is what goes into THING_TYPES.
  • Ammo and Misc change the rules in the RULES table: starting health and bullets, the caps for bonuses and the soulsphere, what the soulsphere and megasphere give, the armour classes, IDDQD's health and IDFA's and IDKFA's armour, BFG cells per shot, max ammo and clip sizes. Every ammo pickup's amount follows the clip size, as P_GiveAmmo counts in clips.
  • Cheat respells the cheat codes, and BEX [PARS] sets par times. Freedoom's own lump has both: its par times show on the intermission, and in Phase 1 IDCLIP and IDSPISPOPD swap places.
  • Frame, Pointer, Weapon, Sound and Text blocks can't be applied. The port has no state tables (each thing type is a state machine with frame letters), so they're listed in the console instead. Freedoom's seven frame patches (brighter muzzle flashes, a shorter super shotgun flash) are among them.

npm run test:dehacked reads Freedoom's lump and a patch with every kind of block, then plays the patched rules in Firebird.

Testing with id's WADs

The game ships with Freedoom, but the WAD picker loads any IWAD, id's doom.wad and doom2.wad included. Those keep their story texts in the executable rather than the WAD, so without them the Doom I endings go straight to their art. E1M8 shows CREDIT (Ultimate DOOM) or HELP2 (registered), E2M8 VICTORY2, E3M8 the bunny scroll and E4M8 ENDPIC. Doom II's text screens fall back to Freedoom Phase 2's story text (C1TEXT–C6TEXT, BSD-licensed). The build extracts it from the bundled freedoom2.wad into wads/freedoom-strings.json, next to FREEDOOM-COPYING.txt. So with doom2.wad the screens after MAP06, MAP11 and MAP20, the secret exits and MAP30 still appear, though their story is Freedoom's. Without that file they're skipped and MAP30 goes straight to the cast call. No id text is ever reproduced. id's WADs also bring the intermission's splats, "you are here" pointer and episode-map animations that Freedoom leaves empty. To see a screen without playing to it, use the browser console:

doom.finale('E3M8')              // the bunny scroll
doom.finale('MAP15', true)       // the screen on MAP15's secret exit
doom.intermission('E2M3', 'E2M4', { kills: 20, totalKills: 20 })

Afterwards the game goes on to the next map, as if you'd just finished the one named, except after DOOM I's endings, which end the game. npm run test:finale simulates both id layouts by hiding Freedoom's DEHACKED (and E4M1). npm run test:intermission draws the episode maps through the real renderer and checks them pixel by pixel: the splats on the levels done, the pointer blinking on the next, every animation. It lends Freedoom's placeholders a picture of its own (the status bar face) to do that. If you have id's WADs, point the two tests at them with WAD=. The WADs stay on your disk; nothing copies them:

WAD=~/wads/doom.wad npm run test:intermission    # every episode-map picture real, and drawn where it belongs
WAD=~/wads/doom.wad npm run test:finale          # each episode's end goes straight to its art
WAD=~/wads/doom2.wad npm run test:finale         # the cast call's frames, Freedoom's borrowed text

The title screen and the menus

The game opens on the title loop (D_DoAdvanceDemo, src/menu.js): TITLEPIC to the title music (D_INTRO, D_DM2TTL on DOOM II), a demo, the credits page, a demo, the credits again (HELP2 on a three-episode WAD; DOOM II shows TITLEPIC again), a demo, round and round – DOOM II has a fourth demo after the third. DOOM's demos are its .lmp files, which this port can't play (see Demos below), so these are its own: for each Freedoom WAD a scripted player (npm run attract, scripts/record-attract.mjs) runs the first maps for twenty seconds, shooting what it sees, and the recordings ship in public/demos/. The demo plays through the game loop like any other, melting in and out; any key brings up the menu over it while it goes on, as DOOM's G_Responder has it; starting a game ends it. A WAD of your own has no demos, and its title loop skips them (DOOM would stop for the missing lump). npm run test:attract replays each demo against the checksum stored in it, so a change to the simulation that alters how they play out is caught, and they're recorded again. Any key brings up the main menu (m_menu.c), drawn with the WAD's own M_* graphics and the blinking skull. In play, Esc opens it, and so does letting go of the mouse. The game waits behind it, as DOOM's single player does (in co-op it goes on). Arrows move, Enter chooses, Backspace goes back, Esc closes, and each menu remembers where its cursor was.

  • New Game: episode (DOOM I, as many as the WAD has), then skill. Nightmare first asks you with the WAD's own NIGHTMARE text.
  • Options:
    • End Game, back to the title.
    • Messages on/off.
    • Graphic Detail.
    • Screen Size (see below).
    • Mouse Sensitivity.
    • Sound Volume, two thermometers tied to the page's sliders.
  • Read This! (DOOM I): HELP1, then HELP2 (or CREDIT on a four-episode WAD).
  • Load Game and Save Game: six slots per WAD, in DOOM's bordered boxes (see below).
  • Quit asks with one of the WAD's quit messages, then plays one of DOOM's quit sounds and goes back to the title. A browser tab can't quit.

Menu sounds are DOOM's: a click on cursor moves, a pistol shot on choosing, and the switch sounds when it opens and closes. The settings under the view still work as before and stay in step with the menu. npm run test:menu drives all of it headless.

Screen size

The menu's Screen Size thermometer, or - and = during play (while the automap is closed), sets DOOM's screenblocks from 3 to 11, as R_SetViewSize does. 10, the default, is the full-width view over the status bar. 11 is the whole 320×200 screen with no status bar, which comes back while the automap is open. Below 10, the view is blocks × 32 pixels wide and (blocks × 168 / 10) & ~7 tall, centred over the status bar. Around it is R_FillBackScreen's border: the FLOOR7_2 flat (DOOM II's GRNROCK) with the BRDR_* patches bevelling the edge. A smaller view is a smaller FRAME_WALLS: Firebird projects and clips fewer columns and rows, so the frame rate goes up. On 16 views of E1M1 and E1M2, size 6 took FRAME_WALLS from 29 ms to 18 ms. The vertical scale follows the view's width (yslope's (viewwidth << detailshift) / 2), and so does the weapon (pspritescale). The light tables are rebuilt for the width, as R_ExecuteSetViewSize rebuilds scalelight, so a wall is exactly as bright at every size, which npm run test:light checks.

R_DrawPSprite places the weapon against the view's centre, and BASEYCENTER (100) is the centre of the full screen. So at size 10, with the view 168 tall, the weapon sits 16 pixels higher than at size 11. The port used to draw it at the full-screen height at every size, 16 pixels too low. The setting is remembered with the others.

Save and load

The whole game lives in Firebird, so a save is a snapshot of the rows that change while you play (src/savegame.js, after G_SaveGame/G_LoadGame):

  • GAME, PLAYER, THINGS and MOVERS, entire.
  • The map's moving parts: sector heights, lights, specials and the heard-gunfire flag; switch textures on sidedefs; one-shot linedef specials.
  • Two things the page keeps itself: the automap's seen lines, and DOOM I's visited secret levels.

Loading reloads the map with loadMap, which rebuilds the static tables and the blockmap. Then it writes the snapshot back and moves thing_seq past the restored ids. Fractions go back as m × 2^e, because a double written out as decimal can come back from Firebird one bit off, and a load must be exact. Save Game in the menu works only during a game. Pick a slot, type a description (up to 23 characters, Backspace to fix, Esc to cancel), and press Enter: "Game saved.". Load Game lists the six slots, and an empty one can't be chosen. Saves live in your browser's IndexedDB, six slots for each WAD, keyed by its file name and map count, and they survive reloads. An E1M1 save is about 70 KB. npm run test:savegame plays a map, saves, plays on, loads, and checks that every saved row comes back exactly.

F6 quicksaves and F9 quickloads, as M_QuickSave and M_QuickLoad do. The first F6 of a session opens Save Game, and the slot you save into becomes the quicksave slot. After that, F6 asks before writing over that slot's game under its description, and F9 asks before loading it (or says there's no quicksave slot yet). F6 outside a game just grunts. The other function keys are M_Responder's too, with the menu down: F1 Read This! (DOOM II's HELP page), F2 Save Game (or the "only during a game" message), F3 Load Game (refused in a netgame), F4 Sound Volume with the cursor on the effects slider, F5 toggles the detail and says so, F7 asks to end the game (a grunt outside one), F8 toggles messages – that message shows even when they're off, as DOOM's does – F10 asks to quit, and F11 steps the gamma correction (below). Each plays the switch sound as DOOM does. The questions come from the WAD's DEHACKED (QSPROMPT, QLPROMPT, QSAVESPOT) when it has them. Freedoom's doesn't, so the wording here is our own. Some browsers keep F6 for the address bar when the page doesn't have the focus, so click the game first. npm run test:menu checks each step.

Saves → Export under the view downloads the current WAD's six slots as one JSON file, and Import… reads such a file back into this browser, or another one. It writes the slots the file has filled and leaves the rest alone, and asks first before replacing a save. A file made for another WAD, or by another save version, is refused, since its map names and thing ids wouldn't fit. DOOM has no such thing: its saves were already files (DOOMSAV0.DSG…). Here they live in IndexedDB, so this is how to back them up or move them. npm run test:savegame checks the round trip, the refusals, and that an imported save restores exactly.

A load writes every saved row back as bound parameters: one statement per table, prepared once and run for every row in a single call (firebird-wasm's execBatch). Since firebird-wasm 0.4 a number is bound in binary, so every double, positions and momentum included, comes back bit for bit. Written out as decimal text, Firebird's parser lands about one fraction in ten a bit off: the save test measures both. Maps and resources load the same way, about three times faster than the EXECUTE BLOCKs of INSERTs they replaced: Freedoom 2's resources in 83 ms instead of 484, MAP11 in 1.1 s instead of 3.3.

Saves made by older versions keep working. scripts/fixtures/saves/ holds a save from every version whose saves looked different, twelve of them so far, from the first one that could save up to today. npm run save-fixtures made them: it checks out each commit that changed the schema or the save code into a temporary git worktree, plays E1M1 there with that version's own code, and keeps one save per distinct set of tables and columns. The save test loads every one of them with today's code. Every saved value must come back and the game must play on. Columns added since then take their defaults, and so far every default suits an old game: one player, no netgame options, a raised weapon, a fresh chase. A schema change that breaks an old save fails the test and names the table and column. After a schema change, npm run save-fixtures adds the new shape.

A save from someone else is checked before any of it reaches the database. That covers an imported file and a netgame host's save. Its table and column names go into SQL, so each must be one this version has, and every row must be whole and hold plain values. Otherwise the load stops and says what's wrong.

Demos

A demo is the input of a game (after G_RecordDemo/G_DoPlayDemo, in this port's own format, src/demo.js). ● Record under the view restarts the current map with a fresh random seed and records every DOOM_TIC call: [tics, fwd, side, turn, fire, use, weapon, run]. As in DOOM, a demo goes on from level to level. Through an exit it records the intermission's frames (['wi', tics, buttons], the presses that hurry it along), a text screen's (['fin', tics, held, pressed]), and each level start (['map', name, seed, newGame]). That includes the restart after a death, which starts with a fresh seed the demo keeps. It stops with ■ Stop, or when you start something else yourself (a new game, the Map selector, a load). ▶ Play loads the same map at the same skill with the same seed and makes the same calls, and the same game unfolds, screens and all. If the game ever takes a different turn from the recording, playback stops and says it's out of step. Esc stops it. Download saves the demo as JSON, and Load… plays one from a file. One-level demos from before (version 1) still play. The last demo of each WAD is kept in the browser. Playback runs in the game's own time: as many of the recorded calls as the tics gone by cover, whatever frame rate the recording was made at.

That works because the game is deterministic. Every chance in sql/game.sql goes through P_RANDOM(), a linear congruential generator whose state is the GAME.RNG column (so a save keeps it too), and every load numbers the things from 1. Cheats are switched off while recording or playing, because they aren't inputs and would desync the demo. DOOM's own .lmp demos can't be played: they need DOOM's exact fixed-point simulation and random table, and this is a re-implementation in SQL. npm run test:demo replays a busy 531-tic recording and compares every row of the resulting game, on both WADs.

Co-op and deathmatch over the network

Players 2, 3 and 4 seen by player 1 on E1M1: grey, brown and red, the one in the middle shooting

Up to four players can play a level together, or against each other, each in their own browser, peer to peer (src/net.js). Open Play over the network under the view. With no server to introduce the players, they swap two codes by hand (a chat message will do). The host presses Invite a player and sends the code. The guest presses Join, pastes it and presses Connect, then sends back the reply code. The host pastes the reply and presses Connect. That makes a WebRTC data channel between the two browsers: the codes are the SDP offer and answer, complete with their ICE candidates, found through a public STUN server. The host can invite up to three players this way, picks Co-op, Deathmatch or Deathmatch 2 and a Timer, then presses Start. Everyone loads the host's map at the host's skill, with the host's random seed. Everyone must have the same WAD loaded: a guest with another one is told which.

With a relay (worker/relay.js, a small Cloudflare Worker) it gets simpler. The host's invite becomes a six-letter room code, like 5HVDTX. The guest types it (in any case, dashes allowed), and the reply goes back through the relay, so there's nothing to paste back. The relay also hands out TURN servers with short-lived credentials. Behind a strict NAT (some mobile networks, some offices) two browsers can't reach each other directly; with TURN, the game goes through Cloudflare's relay instead. Only the handshake and, when it's needed, that relayed traffic touch the server; the game itself is still peer to peer. The site uses the relay set at deploy time (RELAY_URL), and the panel's Relay field or ?relay=URL points it at another. Without one, codes go by hand as above, through STUN only. Deploying it takes a Cloudflare account and four commands (worker/README.md). npm run relay runs it locally, npm run test:relay checks it, and the co-op test ends with two pages joining by a room code.

It plays the way DOOM did over IPX: in lockstep (d_net.c). Nothing about the game crosses the wire but each player's ticcmd, once a tic: forward, strafe, turn, fire, use, weapon and run. The host gathers every player's command for a tic and sends the set to everyone, and every browser runs that tic through Firebird's NET_TIC, which runs PLAYER_THINK for each player and then the world. Every browser runs the same game from the same commands, because the simulation is deterministic (see Demos). Each browser looks through its own player's eyes (VIEWCFG.PLAYER_ID, DOOM's consoleplayer): its own view, status bar, messages and pickup sounds. Commands go out three tics ahead, so the host has them in time, and nobody runs ahead of a player whose commands haven't come. Every 35 tics each browser sends the host a checksum of its game (the tic, the random seed, where every player and monster is and how hurt). Any difference is a desync, and the game stops for everyone and says so, as DOOM's consistency check did. It also stops when a player leaves.

The simulation knows about every player, after vanilla. Each starts on their own player start, and the things marked multiplayer-only appear. Monsters look for any player they can see (P_LookForPlayers) and go after the player whose gunfire woke them. Missiles and hitscan hit any player but the shooter, so friendly fire is on. Kills count for the player who made them. A dead player presses use to come back at their start with a pistol (G_DoReborn), and the level goes on. A level exit takes everyone to the intermission. Each player hurries their own along, and the next map waits for the last of them. The menu doesn't stop a netgame. Pause and save do work, the way DOOM did them: through the ticcmds. The pause key, or a save from the menu or F6, goes out as a special command (BT_SPECIAL with BTS_PAUSE or BTS_SAVEGAME and the slot), with no fire, use or weapon change that tic. Every peer acts on it on the same tic (G_Ticker). A pause stops the world everywhere, while the tics keep going round so anyone's next pause press can end it. A save makes every browser write the same game into the same slot once that tic has run. It carries the saver's description there, and NETGAME on everyone else's. Loading during the game stays out, as in DOOM: F3 and F9 say so. A netgame starts from a save instead, as vanilla's -loadgame did. The host picks one of its saves under Start from, and its map, skill and rules come with it. The save goes gzipped in the start message, about 20 KB for E1M1. Each guest checks it like the rest of the message and restores it before the first tic. A save made in a netgame is for that many players, and the panel says so if the count doesn't match. Cheating, demos and warping are out too, as they aren't in the ticcmds. npm run test:netgame plays two players in one database, two peers through a pretend network that delivers late and out of order, and four peers through the host. npm run test:coop connects two real pages over WebRTC, with the codes swapped through the panel, and plays them in lockstep through a level exit and the intermission into the next map, then a deathmatch. On the way it pauses from the host and goes on from the guest, saves from the host into both browsers, and starts a netgame again from that save. Last, four pages play together, the host relaying for three guests. Each guest's keys move only their own player, and the four games agree while everyone fires. A chat to one player (G, I, B or R) reaches only them, and when one guest leaves, the other two are told. That last part was missing before the test: a guest's reason for stopping reached only the host, so the other guests froze without a word.

The deathmatch intermission: killers down the side, victims along the top, each player's frags of each other and their totals

Deathmatch plays by g_game.c and p_inter.c. Everyone starts on one of the map's deathmatch starts, picked at random (G_DeathMatchSpawnPlayer: up to twenty tries for a free one, then your own player start), and comes back on another after each death. Keys aren't placed (MF_NOTDMATCH) and everyone carries all of them (P_SpawnPlayer). A placed weapon stays where it lies in any netgame (P_GiveWeapon: "leave placed weapons forever on net games"), giving the weapon the first time and five clips of its ammo every time, with the pickup sound for you alone and no message; co-op has that too. Killing a player is a frag (P_KillMobj), and killing yourself counts against you: the status bar's arms give way to your frag count (st_fragson), the others you killed less the times you killed yourself (ST_updateWidgets). In any netgame your face sits on your colour (STFB0–3). The frags of the level end on the deathmatch intermission: the frag matrix, killers down the side and victims along the top, with each player's total (WI_drawDeathmatchStats), the counts going up one a tic. Co-op has its own screen too: a row per player with their kills, items and secrets, and their frags if there were any (WI_drawNetgameStats). Deathmatch 2 is -altdeath: weapons are taken like any other item, and whatever is picked up comes back where it was thirty seconds later in a puff of fog (P_RespawnSpecials, one a tic), except the invulnerability and the invisibility spheres and what a monster dropped. The Timer is -timer: the level ends after that many minutes, through the intermission to the next map. The frags start over with each level, as in DOOM. Not in DOOM, and not here: a frag limit, and an obituary when someone dies. No deathmatch starts in a map means the player starts instead (DOOM refuses a map with fewer than four).

The three checkboxes beside the timer are DOOM's launch options, the host's choice for everyone, at any skill: No monsters is -nomonsters (P_SpawnMapThing leaves out everything that counts as a kill, and the lost souls, so there's nothing to kill and the intermission says so), Respawn is -respawn (dead monsters get back up as on Nightmare, P_NightmareRespawn) and Fast is -fast (the monsters move and attack at Nightmare's pace: demons at double speed, imp, cacodemon and baron fireballs at 20, no waiting between attacks; only Nightmare itself also takes away their reaction time when they spawn, as in P_SpawnMobj). They go in the start message with the game type and timer, and sit in GAME (NOMONSTERS, RESPAWN, FAST) for INIT_MAP, WORLD_TIC and MONSTERS_THINK to read, where Nightmare already took those paths.

The same three are command-line options for any game in DOOM, so they're also under the view beside Skill, for a game alone. Like the Skill setting, ticking or unticking one starts a new game on the current map. They hold for that game from level to level, as DOOM keeps them until it quits. Like a command line, they aren't remembered for the next visit. A netgame takes the host's instead. A demo keeps the options it was recorded with in its header, as DOOM's demos did (respawnparm, fastparm, nomonsters). It plays back with those whatever is ticked, and the title loop's demos always play without any.

Chat is hu_stuff.c's. T opens a line to everyone, and with three or more players G, I, B or R opens one to green, indigo, brown or red alone (your own colour's key gets you talking to yourself). You type, Backspace takes a character back, and Enter sends the line. While it's open, Alt+0–9 sends a chat macro instead (chat_macros[]). DOOM's own macros are id's text, so these are ours, and a DEHACKED's HUSTR_CHATMACRO0–9 replace them. As in DOOM, the text travels inside the ticcmds, one character a tic (chatchar, from HU_dequeueChatChar), so it needs nothing beside the lockstep and never touches the game. Every peer puts the other players' lines together (HU_Ticker), and a finished one meant for you shows by the sender's colour with a beep (DSTINK, or DSRADIO in DOOM II). It stays up over the game's own messages and shows even with messages off. The colour names come from the WAD's DEHACKED: Freedoom calls the players g:, i:, b: and r:. The line you're typing shows under the message line, with its cursor. Keys the line can't take, like the arrows, still reach the game, and Esc opens the menu, as they did in DOOM. src/chat.js holds it all.

The others look as they did in DOOM. Each player's sprite goes through info.c's S_PLAY states, moved on a tic at a time by PLAYER_ANIM: standing (A), running (A to D, 4 tics a frame), a shot (F, the muzzle flash, for 6 tics, then E for 12), pain (G), dying (H to N) or, below -100 health, gibbed (O to W). The body stays where they fell when they come back. Players 2, 3 and 4 wear player 1's green in other colours (R_InitTranslationTables): palette entries 0x70–0x7F become 0x60–0x6F (DOOM's "indigo", a grey), 0x40–0x4F (brown) and 0x20–0x2F (red). THINGS.TRANSLATION says which, FRAME_SPRITES passes it on, and the rasteriser looks each pixel up in the table before the light (R_DrawTranslatedColumn).

Skill levels

The Skill setting (1–5, default 3) picks which things spawn: easy, normal or hard, by each map thing's flags. Changing it starts a new game on the current map, as DOOM's New Game menu would. The skills follow DOOM's rules:

  • 1 (easiest): you take half damage (P_DamageMobj), and ammo pickups give double (P_GiveAmmo), weapons and backpacks included.
  • 2–4: the spawn flags change, nothing else.
  • 5 (Nightmare):
    • Double ammo.
    • Fast monsters: demons run twice as often, and imp, cacodemon and baron fireballs fly at 20.
    • Monsters react and attack again without hesitating.
    • Corpses come back (P_NightmareRespawn). Every 32 tics, each one that has lain 12 seconds has a 5-in-256 chance to rise at the spot the map put it, at full health and in teleport fog, if there's room. Lost souls don't come back.
    • The cheats don't work, except IDCLEV and the automap's IDDT, as in ST_Responder.

Cheats

Type IDDQD, IDKFA, IDFA, IDCLIP, IDCHOPPERS, IDBEHOLDx, IDMYPOS, IDMUSxy or IDCLEVxy any time during play (ST_Responder). The browser spots the letters and calls EXECUTE PROCEDURE cheat('iddqd'). The SQL console has buttons for both.

  • IDDQD toggles god mode (PLAYER.GOD, CF_GODMODE). It heals you to 100, and DAMAGE_PLAYER then ignores every hit below 1000, the same rule as invulnerability. The face turns gold. E1M8's exit floor switches it off, a new game clears it, and it carries over from level to level.
  • IDKFA hands over every weapon, ammo up to your current maximums, 200 armour and all three keys. The super shotgun comes only on DOOM II maps. IDFA does the same without the keys ("Ammo (no keys) Added").
  • IDCLIP (or DOOM I's IDSPISPOPD) toggles no clipping (PLAYER.NOCLIP, CF_NOCLIP). P_CheckPosition says yes before looking at a single line or thing, so you walk through walls and monsters. No lines are checked, so none trigger as you cross them. Your height still follows the floor beneath you.
  • IDCHOPPERS hands over the chainsaw ("... doesn't suck - GM"). Like vanilla, it also sets invulnerability to true, which is one tic: it cancels a running invulnerability sphere.
  • IDBEHOLD on its own lists the choices: "inVuln, Str, Inviso, Rad, Allmap, or Lite-amp". Followed by v, s, i, r, a or l, it toggles that power-up. Switching one on gives the full time P_GivePower would, and berserk also heals you to 100. Switching one off leaves it one tic to run, and berserk goes straight to 0. As in vanilla, the computer area map can't be taken back.
  • IDMYPOS shows where you are the way DOOM printed it, ang=0x…;x,y=(0x…,0x…): the angle in BAMs and x/y in 16.16 fixed point, as 32-bit hex. A small PSQL function, HEX32, does printf's %x.
  • IDMUSxy changes the music ("Music Change"). DOOM I reads the digits as episode and map, up to vanilla's 32nd song (E4M5). DOOM II reads them as the song number, 1 to 35: 31 and 32 are the secret levels' EVIL and ULTIMA, and 33–35 the story screens', title and intermission tunes. IDCLEV 31 and 32 reach MAP31 and MAP32. Anything else, or a song the WAD lacks, gets "IMPOSSIBLE SELECTION". It lives in the browser, next to the synthesiser.
  • IDCLEVxy warps, as G_DeferedInitNew does: a new game on that map, inventory reset. DOOM I reads the digits as episode and map (idclev13 is E1M3), DOOM II as the map number (idclev07 is MAP07). Maps the WAD doesn't have are ignored. This cheat lives in the browser, because loading a map is the loader's job. src/cheats.js holds the cheat readers, tested headless.

The SQL cheats say what they did ("Degreelessness Mode On", "Very Happy Ammo Added", "No Clipping Mode ON"). A dead player can't cheat.

Sound and music

The simulation decides what you hear. PLAY_SOUND inserts a row into SOUND_EVENTS (sound lump, origin, map position) for:

  • the player: gunfire, pain and death, a hard landing, "oof" against a wall, pickups
  • monsters: sighting, attacks, pain and death (per type, from THING_TYPES), and the growls they make now and then while chasing you. That's A_Chase's activesound, 3 times in 256 per chase step: DSPOSACT for the former humans and the mancubus, DSBGACT for imps, DSDMACT for the demons and the rest, DSBSPACT, DSSKEACT and DSVILACT for the arachnotron, the revenant and the arch-vile
  • the world: doors, lifts, switches, teleports, exploding fireballs and barrels

Each frame the browser reads the rows it hasn't seen, in the same pipelined batch as the render queries. It plays the WAD's DMX sound lumps through Web Audio, on DOOM's 8 channels (src/channels.js), as s_sound.c does:

  • Starting a sound (S_StartSoundAtVolume): a sound out of earshot isn't started. Otherwise the origin's previous sound stops, and S_getChannel takes a free channel. If none is free, it takes the first channel playing a sound that matters no more than the new one, by sounds.c's priorities: a lower number matters more, so the teleport is 32, the pistol 64 and a zombie's growl 120. If there's no such channel, the new sound is lost, as in DOOM.
  • Loudness (S_AdjustSoundParams): the distance is P_AproxDistance's. Full volume within 200 units, falling to nothing at 1200. Separation is 128 − 96·sin of the angle to the listener. On map 8 (E?M8 and MAP08, the boss maps) a sound never fades below 15 of 127, however far away.
  • Every frame (S_UpdateSounds): each sound follows its thing as it moves (the page asks Firebird where the things that are sounding are now), and stops once out of earshot. A thing that is removed takes its sound with it (P_RemoveMobj's S_StopSound), which cuts a rocket's explosion short when the explosion is gone, as in DOOM.

The Spider Mastermind's and the Cyberdemon's sight and death sounds are heard at full volume wherever they are: A_Look and A_Scream start them with no origin for those two.

npm run test:sound checks the distances, the priorities, the channel stealing and the following.

Music comes from the WAD's D_* lumps (MIDI in Freedoom, MUS in the original IWADs), played the way DOOM played them on an AdLib or Sound Blaster:

  • The chip (src/opl.js) is an emulated Yamaha YM3812 (OPL2), sample by sample at its own 49,716 Hz. It's written from how the chip works, not ported from another emulator: 9 channels of 2 operators (FM or additive, with feedback), phase counters, a quarter-sine table in the log domain and an exponent table back (the chip never multiplies), the 4 waveforms, envelopes with their counter-driven rate steps and key scaling, total level, key scale level, tremolo and vibrato.
  • The driver (src/dmx.js) is DOOM's DMX, after Chocolate Doom's reconstruction of it (i_oplmusic.c). The WAD's GENMIDI instruments are loaded operator by operator, carrier first. There are 9 voices, and with none free it takes a second voice of a two-voice instrument, or the one on the highest channel. Note and channel volume go through DMX's volume curve into the carrier's level. Pitch comes from DMX's frequency table, in 1/32 semitone steps that the pitch bend moves (the table is computed: within a step of DMX's own, a few cents at most). Percussion plays GENMIDI's 47 drum patches at their fixed notes. It's mono, as DMX drove the OPL2.
  • OPL3, the Synth setting under the view: the chip becomes a YMF262, the Sound Blaster 16's, and the driver drives it as DMX's -opl3 option did (Chocolate Doom's opl3mode): 18 voices, the second nine on the chip's second register array, and each voice sent to the left, the right or both by its channel's pan (MIDI controller 10, from the MUS file). DMX had the sides the wrong way round – a pan to the right drives the left output – and Chocolate Doom keeps that as its default, so this does too. The chip's OPL3 half: NEW mode (register 0x105), without which it's an OPL2 (the second array silent, the pan bits ignored, both sides the same); the per-channel output bits; and four more waveforms (alternating sine, camel sine, square, logarithmic sawtooth), which GENMIDI never asks for. Not emulated: 4-operator channels, rhythm mode and the timers – DMX uses none of them. Changing the chip starts the song over.
  • Playback runs in an AudioWorklet (src/opl-worklet.js), on the audio thread, away from the game. Events land on the exact chip sample, and the output is resampled to the sound card's rate, in stereo (the same on both sides from the OPL2).

The emulator plays about 25 times faster than real time. Volumes are under the view, and audio starts after your first click or key press (a browser rule). In the devtools console, doom.audio.renderLevel('D_E1M1') renders a few seconds and reports the level. npm run test:music checks the chip against its documented behaviour (pitch, levels, envelopes, waveforms, feedback, tremolo, vibrato; the OPL3's arrays, sides and waveforms), the driver's register writes on both chips, and songs from both WADs, mono and stereo.

Documentation

  • docs/ARCHITECTURE.md: how it's built. Files, a tic, a frame, palette indices and presenters, the screens, the tests, and the Firebird gotchas.
  • docs/ROADMAP.md: what's still missing.
  • CLAUDE.md: working notes for coding agents (commands, conventions, pitfalls).

Offline play

Once the page has loaded, it also plays without a connection. A service worker (public/sw.js) stores the page's own files when it installs: the HTML, the code, the Firebird engine and its .wasm, the music worklet, the title loop's demos and Freedoom's story text, about 10 MB. Each build stores its own complete set (the build stamps sw.js with a hash of them), and the next build's replaces it. A WAD the page downloads goes into a cache of its own, as it came, so it's never fetched twice. Phase 2 is there offline only if it was played once. Online, everything still comes from the network first, so a new version (or a rebuild on the dev server) shows at once; the caches are only what's left when the network isn't there. Offline, a WAD that was never downloaded says so. doom.offline() in the console lists what's kept. npm run test:browser ends by loading the page once, stopping the server, reloading and playing a level.

Running locally

npm test            # the SQL smoke test
npm run test:all    # every test, as many at once as there are cores

npm run fetch-wad downloads Freedoom and writes public/wads/freedoom1.wad and freedoom2.wad, minus PC-speaker sounds and demos. The page's Game setting switches between them. Phase 1 plays like DOOM (E1M1–E4M9). Phase 2 plays like DOOM II (MAP01–MAP32), with the super shotgun, the megasphere and DOOM II's monsters. Phase 2 downloads only when you pick it. npm test runs the game SQL in the real WASM engine under Node. npm run serve builds dist/ and serves it on http://localhost:8080 without COOP/COEP headers, just like GitHub Pages. The page doesn't need them: it runs firebird-wasm's build without threads, which needs no SharedArrayBuffer, so there's no cross-origin isolation and no service worker. It was also the faster of the two here (a tic ~15 ms against ~18, a map load ~0.5 s against ~0.7, and the engine starts in half the time).

You can also load your own DOOM1.WAD / DOOM.WAD / DOOM2.WAD with the file picker. Nothing is uploaded anywhere.

npm run test:all-maps loads, plays and renders every map in the WAD, and fires each map's first teleporter. npm run test:weapons fires the rocket launcher, plasma gun, BFG, chainsaw and, when the WAD has it (WAD=public/wads/freedoom2.wad), the super shotgun at a monster. It checks ammo, sounds, damage, splash and pickups.

npm run test:specials checks:

  • that hanging decorations hang
  • that the boss-death specials fire (E1M8, E2M8, E3M8, MAP07)
  • that the last Commander Keen opens the 666 door
  • that the Icon of Sin spits cubes, spawns monsters and ends the game when its brain dies
  • the DOOM II attacks: revenant missiles turn exactly 16.875° per update and find you, the revenant punches, a mancubus attack fires six fireballs at DOOM's spread angles, and the arch-vile's flame follows you before the blast throws you upwards, the arch-vile raises a corpse, and a dying pain elemental releases lost souls (but never past 20)

Use WAD=public/wads/freedoom2.wad for the Phase 2 parts. npm run test:physics drives real crushers (descent, damage, gibs, stop and resume, floor crushers) and checks missile slopes, autoaim with its 5.625° fallback, floor impacts and the sky. It checks infighting: a fireball turns a demon on an imp, which it then bites, and the demon returns to you when the imp dies. A fireball bursts harmlessly on its own species, and a zombieman's stray bullets provoke an imp in the way. An arch-vile is always provoked but never provokes, and it flames the demon it's fighting instead of you. It checks that gunfire reaches open sectors but not past a closed door, that it wakes a monster out of sight in earshot but not one out of earshot or in ambush. It also checks that a level hitscan shot passes under a raised imp, that the right slope hits it, and that the pistol finds that slope itself but not beyond DOOM's aiming window. npm run test:renderers compares the BSP and brute-force renderers from several spots and headings on every map, and reports the speed-up. npm run screenshots regenerates docs/*.png. node scripts/bench.mjs queries.sql times SQL statements against a loaded map, with statements separated by -- @@ lines.

Controls

Click the view to capture the mouse. WASD or the arrow keys move, Ctrl or a click fires, Space/E uses, Shift runs, 1–7 pick weapons (fist, pistol, shotgun, chaingun, rocket launcher, plasma gun, BFG9000). As in DOOM II, pressing 1 again toggles the chainsaw and 3 again the super shotgun. A change takes the old weapon down and the new one up. Tab shows the automap (type IDDT on it to reveal everything), P pauses, Esc opens the menu, and the function keys do what DOOM's do: F1 help, F2 save, F3 load, F4 sound volume, F5 detail, F6/F9 quicksave and quickload, F7 end game, F8 messages on/off, F10 quit, F11 gamma correction. Under the view you can set Detail (320 or 160 columns), Renderer (BSP + solidsegs, or brute force), Skill (1–5), No monsters, Respawn and Fast (DOOM's command-line options, for this visit only), Display (WebGL palette shader or Canvas 2D), Smooth upscaling, Mouse moves forward, Audio on/off (M), and Sound and Music volume. These settings are remembered in your browser. The SQL console under the game queries the live game database. Try the IDKFA button.

The mouse works as G_BuildTiccmd has it, with DOOM's default buttons. The left button fires. Holding the right button (DOOM's button 2) turns the mouse's sideways motion into a strafe, and the turn keys strafe too while it's held. Holding the middle button (button 3) walks forward. A double click on either is use, as in DOOM: two presses with each change more than a tic after the last, within 20 tics. Strafing and walking can add up to more than the keys give, up to DOOM's limit (MAXPLMOVE, running speed). In DOOM, pushing the mouse forward walked you forward (mousey). Most players turned that off, so here it's Mouse moves forward, off unless you choose it. src/mouse.js holds the mouse's half of the ticcmd.

The keys are default.cfg's ten, set in the Keys panel under the view as SETUP.EXE set them. They are forward, back, turn left and right, strafe left and right, fire, use, run, and the strafe key, each with two slots. Click a slot and press the key; Esc leaves it as it was and Backspace leaves it empty. A key does one thing, so binding it takes it from wherever it was. The defaults are the keys above plus DOOM's Alt as the strafe key: held, it turns the turn keys and the mouse into strafing (key_strafe). Left and right Ctrl, Shift and Alt count as one key each, as they did under DOS. The keys the page keeps for itself can't be bound: Esc, Tab, the weapon digits, the function keys, -, =, P, M and T. With the automap open, its own keys (F, G, M, C, 0, and the arrows while it pans) are its alone. The bindings are remembered in the browser (src/keys.js).

A gamepad is DOOM's joystick (src/gamepad.js, through the browser's Gamepad API). Vanilla read one stick as on or off each way, and so does this: the left stick past halfway, or the d-pad. Up and down walk, and left and right turn. Its buttons 0 to 3 are default.cfg's: fire (joybfire), strafe (joybstrafe, held: left and right strafe), run (joybspeed) and use (joybuse). On most pads those are A, B, X and Y. As in M_Responder, the stick moves through the menus, repeating every 5 tics up and down and every 2 left and right. Button 0 is Enter and button 1 is Backspace. On the title any button opens the menu (G_Responder), so a game can start without touching the keyboard. A second stick has no part in it: DOOM's joystick had one.

Command line

DOOM took options on its command line (D_DoomMain's M_CheckParm); the page takes the same ones as URL parameters, so a link can start a given game. A parameter without a value is a switch:

?warp=E1M3&skill=4&fast          doom -warp 1 3 -skill 4 -fast
?iwad=freedoom2&warp=7           doom -iwad freedoom2.wad -warp 7
?timedemo=demo1                  doom -timedemo demo1
  • warp takes a map name (E1M3, MAP07), DOOM's own numbers (1,3 or 1 3 for DOOM, 7 for DOOM II), or IDCLEV's two digits. episode starts that episode's first map, and skill (1–5) sets the Skill. As in DOOM, any of the three starts a game straight away instead of the title.
  • nomonsters, respawn and fast tick the launch options for the visit.
  • nomusic, nosfx and nosound keep the game quiet for the visit, whatever the sliders say.
  • iwad picks freedoom1 or freedoom2 for the visit, leaving the Game setting as it is.
  • record starts recording a demo with the game.
  • playdemo plays a demo: demo1–demo4 are the title loop's, and last is the last one recorded here.
  • timedemo plays one as fast as frames come, a demo tic a frame (G_TimeDemo's singletics). It then reports the game tics, the real time and the frames a second, a benchmark of the whole page and not just of Firebird.
  • deathmatch, altdeath and timer fill in the netgame panel for a host.

Unknown parameters are ignored, as DOOM ignored what it didn't look for. A bad value, like a map the WAD hasn't got, is said in the status line. src/cmdline.js reads them.

Deploying

.github/workflows/pages.yml runs on every push to main. It installs, fetches and caches Freedoom, and runs every test with npm run test:all (scripts/test-all.mjs), in three jobs side by side: the SQL tests four at a time (--part=sql), and each browser test on a runner of its own (--part=browser, --part=coop). The SQL tests are the smoke test, weapons, specials, physics, saves, demos and the netgame on both WADs, the screens, menus, light, sound and music, and the BSP-vs-brute-force renderer check. npm run test:visual renders this README's pictures again and fails if a single pixel differs from docs/screenshots.json. The two browser tests drive the built page in headless Chromium through Playwright: the title, the menus, E1M1 melting in, quicksave and quickload, a level exit, the intermission, E1M2, an ending, End Game and offline play, then co-op and deathmatch over WebRTC, with no page errors allowed. On a failure every test's log (test-logs/) and the pictures that changed are uploaded for a look. Then it builds and publishes dist/ to GitHub Pages. Pull requests run everything except the deploy.

Browsers

The page is tested in three browser engines on every push to main. The main workflow uses Chromium, and .github/workflows/browsers.yml runs the same browser and co-op tests in Firefox and in WebKit, Safari's engine (Playwright 1.56: Firefox 142, WebKit 26). BROWSER=firefox npm run test:browser runs one locally. All three play both tests through. That covers:

  • the engine in its Worker;
  • the OPL2 and OPL3 music in their AudioWorklet (on CI through a PulseAudio null sink, since the runner has no sound card);
  • quicksave and quickload, the function keys, the Keys panel, demos and the title loop;
  • the mouse's buttons and the stand-in gamepad;
  • two pages in co-op and deathmatch over a real WebRTC link, with chat, pause and a netgame save.

Not covered: Safari itself on macOS and iOS (WebKit on Linux is close to it, not the same), pointer lock (a headless page can't take it, so the test moves doom.mouse directly), a real gamepad, and touch. The matrix found one bug, now fixed: a WebKit data channel on its way down can still say it's open and then throw on a send, and it reports that as a page error even when the page catches it. So a failed send now ends the netgame the way a closed link does, and a player who leaves hangs up properly: they send their goodbye, close a second later, and whoever hears the goodbye stops sending to them. It also found that a key tapped faster than a frame (Playwright's press() in Firefox and WebKit) never counts, just as DOOM drops a tap shorter than a tic, so the tests hold their keys for a few tics. Headless Firefox grants pointer lock on a click where headless Chromium doesn't, so there losing it opens the menu, as releasing the mouse does. One problem is still open. In a few co-op runs out of a dozen, WebKit's engine stopped with Out of bounds memory access (a WebAssembly trap) while a second page was starting. Thirty paired starts in a row didn't repeat it. When it happens the co-op test reloads the page once and prints a warning, which is what a player would do.

Simplifications

Monster movement, attack timing and accuracy follow DOOM's rules, not its exact frame tables. That's changing, in phases (docs/ROADMAP.md). DOOM's state table is in Firebird now (src/states.js, see Licence), and so is the machine that runs it: SET_STATE is P_SetMobjState (a state's tics start, its action is called, a 0-tic state goes straight on, S_NULL removes the thing) and STATES_THINK moves on, each tic, only the things whose state ends then (an index on the tic it ends; about 5 µs a tic when none does). The monsters move onto it family by family, and the first families are there: the zombieman, the shotgun guy, the chaingunner and the imp live entirely on DOOM's states. They start partway into their spawn state (P_SpawnMapThing's 1 + P_Random() % tics) and look (A_Look: the gunfire heard in their sector, or the player seen in the 180° ahead), chase (A_Chase: the 45° turns, P_Move and P_NewChaseDir, P_CheckMissileRange with its reaction time, MF_JUSTHIT and MF_JUSTATTACKED), take aim and fire (A_FaceTarget, A_PosAttack's one bullet, A_SPosAttack's three pellets, the chaingunner's A_CPosRefire bursts, the imp's A_TroopAttack: a claw within reach, else a fireball), and feel pain, die and get gibbed (P_DamageMobj, P_KillMobj: the pain chance, the death or xdeath state 0–3 tics short, the dropped weapon) at info.c's timings, with DOOM's three sight and death cries picked at random. Every action is a PSQL procedure under its own name (A_LOOK, A_CHASE, A_BULLETS, …). An old save's zombies and imps join the table where they were. (The imp's pain cry is DOOM's DSPOPAIN now, not the demons' DSDMPAIN.) The other monsters still run on the old rules until their families move. npm run test:states wakes, hurts, kills and gibs them and times each step. Monsters hear gunfire the way DOOM does (P_NoiseAlert). Every shot, from any weapon, floods out from your sector through every open two-sided line. A closed door stops it, and a line flagged sound block lets it through once but never twice. Every sector it reaches remembers. Idle monsters there wake up, even with no line of sight; ambush ("deaf") monsters still have to see you. The flood is a breadth-first search in SQL: each pass is one MERGE over SOUND_LINKS, a sector adjacency graph built at map load, and doors count live through the current sector heights. A shot costs 3 ms on E1M1 and about 40 ms on the largest maps. It reruns at most once a second from the same sector.

Monsters chase the way p_enemy.c does. Each one walks one of eight headings, 45° apart (MOVEDIR), for a random 0–15 steps (MOVECOUNT), and turns its body 45° a step towards it. When the count runs out or the way is blocked, P_NewChaseDir picks again: the diagonal towards its target first, then the straight headings, the bigger difference first, then the old heading, then all eight in a random sweep. It turns straight round only when nothing else is open. That's what makes monsters zigzag, sidestep and wander round pillars. A missile attack waits for MOVECOUNT to run out, except on Nightmare. The step after an attack only picks a new heading (MF_JUSTATTACKED). When its target dies, a monster goes back to standing. A_Look only sees ahead: a monster facing away doesn't notice you unless you come within 64 units, or make a noise. It sees as far as vanilla does, with no distance limit. Like P_CheckSight, every monster sight check first asks the map's REJECT table: a precomputed bit per pair of sectors saying one can't see the other. Firebird keeps it as a row of bits per sector, and REJECT (rejected(s1, s2)) rules out 70–94% of pairs on Freedoom's maps before any line of sight is walked. That makes idle monsters cheaper than before, when the port instead ignored the player beyond 2400 units. P_Move and P_NewChaseDir are stored procedures (P_MOVE, NEW_CHASE_DIR).

Monsters fight each other, as in DOOM. When one monster's attack hurts another, the victim turns on the attacker and won't switch again for 100 chase steps (BASETHRESHOLD). After that, hurting it yourself brings it back to you, and once its target dies it hunts you again. Monster projectiles hit any monster in their way, except members of the shooter's own species, which they burst on harmlessly (hell knights and barons count as one species). Zombie bullets hit whoever stands in the line of fire, and splash damage remembers who caused it. Arch-viles follow DOOM's special rules: their attacks provoke nobody, but an arch-vile that gets hurt always turns on the attacker, grudge or not. Once it's fighting a monster, its flame dances on that monster and the blast lands there, tossing its victim into the air.

Monsters have vertical physics (P_ZMovement). The arch-vile's blast throws its victim up at 1000 / mass units per tic, with DOOM's masses: 10 for you or an imp, which fly about 55 units high, 2.5 for a demon and 1 for a baron. Gravity pulls things back at 1 unit/tic². They bump their heads on low ceilings, and a monster killed in mid-air falls before it lies down. A thing's floor and ceiling are the highest floor and lowest ceiling within its radius (floorz/ceilingz), so one half over a step stays on it. Cacodemons, lost souls and pain elementals fly (MF_NOGRAVITY): they don't fall, and they drift 4 units a tic towards their target's height (MF_FLOAT) until they die, when all but the lost soul drop. A flier that steps into a ledge more than 24 units up, or under a ceiling too low for it, rises or sinks 4 units where it is instead (P_Move's floatok), and holds off drifting towards its target until it gets through (MF_INFLOAT, bit 131072 of THINGS.FLAGS). Fliers also cross drop-offs that stop a walking monster. Lifts carry what stands on them, and push up what's in the air only if the floor catches it.

Crushers work. Ceiling crushers (types 6, 25, 49, 73, 77 and the silent 141) cycle between their top and floor + 8. They deal 10 damage every 4 tics to whatever they squeeze, the slow ones drop to ⅛ speed while crushing, and corpses turn to gibs. Types 57 and 74 stop them, and triggering one again resumes it. Floor crushers (55, 56, 65, 94) rise to ceiling − 8, and type 44 lowers a ceiling once.

Projectiles fly in 3D. Monster missiles climb or dive towards your height, and revenant tracers steer vertically too. Your rockets, plasma and BFG autoaim vertically at the first monster in line (straight ahead, else 5.625° either side, as DOOM does). Missiles burst on floors and ceilings, vanish into the sky, and only hit what they actually reach. Hitscan weapons aim the same way (P_BulletSlope): bullets take the vertical slope to a monster straight ahead or 5.625° to either side, and keep their own heading and spread. The fist and chainsaw aim at melee range. The shot then travels in 3D (P_LineAttack). A two-sided line stops it only if the shot's height where it crosses is outside the opening, a monster is hit only if the shot's height at its distance is inside its body, and puffs appear where the shot actually strikes.

The DOOM II monsters' signature attacks follow DOOM's code:

  • revenant missiles home like A_Tracer, turning up to 16.875° every 4 tics and trailing smoke

  • the revenant punches at close range (A_SkelFist, 6 × 1d10)

  • the mancubus fires three volleys of two fireballs (A_FatAttack1/2/3)

  • the arch-vile conjures a flame on you that follows you while it can see you, then blasts you for 20 and throws you upwards, while the flame explodes for 70 (A_VileTarget, A_Fire, A_VileAttack)

  • the arch-vile raises corpses it walks past (A_VileChase): glowing hands, then the corpse plays its death backwards and gets up with full health; it won't raise lost souls, cyberdemons, spider masterminds or other arch-viles

  • the pain elemental spits lost souls (A_PainShootSkull), and three more when it dies (A_PainDie), never past 20 and only where there's room

  • lost souls charge (A_SkullAttack): 10 tics facing you, a scream, then a flight at 20 units a tic aimed at your middle. They keep flying until they hit something. Whatever they hit (you, a monster, a pillar) takes 3–24 damage, and a wall stops them too. Floors and ceilings bounce them, a shot in mid-flight stops them dead, and their range check counts half the distance, so they charge from far off. Souls spat out by a pain elemental charge at once

Large maps with many monsters awake at once can still drop below 10 fps. The Low detail setting (160 columns, like DOOM's own) halves the render cost.

Credits

  • Engine: Electric Firebird (firebird-wasm, Apache-2.0)
  • Game data: Freedoom (BSD-3-Clause; its licence ships as wads/FREEDOOM-COPYING.txt, covering the WADs and the story text derived from them)
  • DOOM © id Software. This is an SQL re-implementation that reads the WAD format. DOOM's state table (src/states.js) is taken from id's source release (GPL-2), through Chocolate Doom's info.c.
  • Inspired by SQL DOOM / DOOMQL by CedarDB and duckdb-doom

Licence

Firebird DOOM's own code is MIT (LICENSE). One file isn't: src/states.js, DOOM's state table (each frame of every thing's animation, how long it lasts and what it does), generated from id's info.c by npm run gen-states. It's GPL-2.0-or-later like its source (LICENSES/GPL-2.0.md). The page bundles it, so the site as built and deployed is distributed under the GPL as a whole; dist/ ships both texts (LICENSE.txt, GPL-2.0.txt). Freedoom's WADs are BSD-3-Clause (above).