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.
![]() |
![]() |
![]() |
|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
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_COLStook 65 s as an inner join, because the optimizer drove from the wrong side. WithCROSS JOIN LATERALorLEFT JOINit 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:
- dead;
- the evil grin for two seconds after picking up a weapon you didn't have;
- when something hurts you, a look towards it (left, right, or the rampage face if it's within 45° ahead);
- the rampage face when the world hurts you (slime, a crusher);
- the rampage face after two seconds of holding the trigger;
- the god face, with IDDQD or invulnerability;
- 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
LUMINANCEtexture. 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
ImageDataandputImageData. 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_LevelInitdoes. - 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 areMOVERSrows queued at map load, a waiting door beingDIR0 and a door waiting to riseDIR2, as inT_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:
CREDITon a four-episode WAD like Freedoom's,HELP2otherwise. - E2:
VICTORY2. - E3: the bunny scroll (
F_BunnyScroll), toD_BUNNY.PFUB2slides off to revealPFUB1, 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 intoTHING_TYPES. - Ammo and Misc change the rules in the
RULEStable: 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, asP_GiveAmmocounts 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
NIGHTMAREtext. - 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, thenHELP2(orCREDITon 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,THINGSandMOVERS, 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
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.
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, andDAMAGE_PLAYERthen 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_CheckPositionsays 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_GivePowerwould, 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_DeferedInitNewdoes: a new game on that map, inventory reset. DOOM I reads the digits as episode and map (idclev13is E1M3), DOOM II as the map number (idclev07is 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'sA_Chase'sactivesound, 3 times in 256 per chase step:DSPOSACTfor the former humans and the mancubus,DSBGACTfor imps,DSDMACTfor the demons and the rest,DSBSPACT,DSSKEACTandDSVILACTfor 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, andS_getChanneltakes a free channel. If none is free, it takes the first channel playing a sound that matters no more than the new one, bysounds.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 isP_AproxDistance's. Full volume within 200 units, falling to nothing at 1200. Separation is128 − 96·sinof 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'sS_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'sGENMIDIinstruments 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 playsGENMIDI'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
-opl3option did (Chocolate Doom'sopl3mode): 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), whichGENMIDInever 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
warptakes a map name (E1M3,MAP07), DOOM's own numbers (1,3or1 3for DOOM,7for DOOM II), or IDCLEV's two digits.episodestarts that episode's first map, andskill(1–5) sets the Skill. As in DOOM, any of the three starts a game straight away instead of the title.nomonsters,respawnandfasttick the launch options for the visit.nomusic,nosfxandnosoundkeep the game quiet for the visit, whatever the sliders say.iwadpicksfreedoom1orfreedoom2for the visit, leaving the Game setting as it is.recordstarts recording a demo with the game.playdemoplays a demo:demo1–demo4are the title loop's, andlastis the last one recorded here.timedemoplays one as fast as frames come, a demo tic a frame (G_TimeDemo'ssingletics). 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,altdeathandtimerfill 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'sinfo.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).











