Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
DOOM simulated and rendered inside the Firebird SQL database, running in your browser via Firebird WASM.
GamesJavaScript · HTMLGitHub @mariuzOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
DOOM simulated and rendered inside Firebird SQL, running in the browser via Firebird WASM
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
DOOM, simulated and rendered inside the Firebird SQL database, running entirely in your browser on Firebird 6 compiled to WebAssembly. 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.Read the full README ↗
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.
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 | the lump itself (BLOCKMAP, in its order, for the player), and LINE_BLOCKS, a 128×128 grid built by INIT_MAP |
P_CheckPosition, P_TryMove | CHECK_POSITION; for the player in 16.16, CHECK_POSITION_FX, TRY_MOVE_FX |
P_MovePlayer, P_CalcHeight, P_XYMovement, P_SlideMove, P_ZMovement (the player) | PLAYER_THINK, XY_MOVEMENT_FX, SLIDE_MOVE_FX (SLIDE_TRAVERSE, HIT_SLIDE_LINE), Z_MOVEMENT_FX |
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, |
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:
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.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 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:
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.
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.
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):
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.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.
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.
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 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.
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.
The weapon in your hands runs on DOOM's frame tables, as p_pspr.c has it. You have two
psprites: the weapon (ps_weapon) and its muzzle flash (ps_flash). Each goes through the weapon
states of info.c, with its tics counted down every tic (P_MovePsprites) and its action called
as it's entered (P_SetPsprite). d_items.c's weaponinfo[] (in src/states.js, loaded into
WEAPON_INFO) gives each weapon its up, down, ready, attack and flash states and its ammo.
A_WeaponReady sways the weapon with your movement (player->bob) and fires when you
pull the trigger (P_FireWeapon). The rocket launcher and the BFG fire once per press
(attackdown). The rest keep firing while it's held (A_ReFire).refire). A rocket
leaves 8 tics after the trigger (A_GunFlash, then A_FireMissile). The BFG sounds at once and
fires 30 tics later. The super shotgun fires at 3 tics, then opens, loads and closes in 59 more.
The chaingun fires on both of its 4-tic frames, each with its own flash. The fist and the
chainsaw turn you towards what they hit (A_Punch, A_Saw). The fist only thuds when it
connects.A_Light1, A_Light2, in PLAYER.EXTRALIGHT) until S_LIGHTDONE.pendingweapon). 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 can't fire until it's up: 31 tics, nearly a second, from
one to the other.P_CheckAmmo puts the weapon down and makes the best one with ammo the next. Its order is
plasma gun, super shotgun (with more than 2 shells), chaingun, shotgun, pistol, chainsaw,
rocket launcher, BFG (with more than 40 cells), fist.P_SetupPsprites), and when you
die it drops out of sight (P_DropWeapon).DOOM_TIC returns each psprite's picture (PSP_PIC, FLASH_PIC) and where it is (PSP_SX,
PSP_SY) for the HUD to draw. npm run test:weapons follows each weapon's cycle tic by tic
against info.c.
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.
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.
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 (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:
AM_LevelInit does.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.
Every sector type DOOM uses is handled, as in P_SpawnSpecials and P_PlayerInSpecialSector:
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.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.G_ExitLevel), even if
the hit killed you, as in DOOM.npm run test:physics runs each kind of special.
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:
close30ThenOpen)perpetualRaise), 54/89 stop them where they are,
and 53/87 start them againP_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.
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.
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.
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.
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):
CREDIT on a four-episode WAD like Freedoom's, HELP2 otherwise.VICTORY2.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.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.
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.
A WAD's DEHACKED lump is applied when the WAD loads (src/dehacked.js, after
Chocolate Doom's deh_*.c):
ID #), its eight states (Initial frame to Respawn frame), and the bits the port models (solid, floating, shadow, hanging from
the ceiling). The patched list is what goes into THING_TYPES. A type given another map number
keeps its own states.Sprite number, one of
sprnames[]), frame and full-bright bit (Sprite subnumber), tics (Duration), next state and
misc1/misc2 (Unknown 1, Unknown 2). Frame 966, one past the end of DOS DeHackEd's own
table, lands in the fist's weapon entry, as it did in DOS DeHackEd (Batman Doom's fist never
comes down). The patched table is what goes into Firebird's STATES, and every thing and
weapon plays from it.Pointer N (Frame M), Codep Frame = K) give frame M the action frame K
had as the game shipped. Each action does what DOOM's does whoever calls it: a zombieman given
A_TroopAttack throws an imp's fireball (the actions name their own projectile, and play its
sound, as P_SpawnMissile does).Deselect frame is the up state
and Select frame the down one, as DeHackEd names them), in WEAPON_INFO.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.[CODEPTR] and the other BEX sections aren't
supported. They're listed in the console instead.npm run test:dehacked reads Freedoom's lump and checks its seven frames. It reads a patch with
every kind of block and plays the patched rules in Firebird. It also plays a patch to the frame
tables: an imp that attacks four times as fast, a zombieman with the imp's attack, a pistol that
fires through the shotgun's frames, a reaction time, a sprite swapped, and frame 966's overflow.
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 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.
NIGHTMARE text.HELP1, then HELP2 (or CREDIT on a four-episode WAD).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.
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.
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.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.
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 as a new game
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. 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, DOOM's own: the next of the 256 numbers in rndtable (m_random.c), from an index
kept in the GAME.RNG column (so a save keeps it too). A new game starts it at 0, as
G_InitNew's M_ClearRandom does; the next level and the restart after a death carry on. 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 yet: 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.
Getting there is the roadmap's stretch goal, in phases (docs/ROADMAP.md, Original .lmp demos),
measured against vanilla itself:
src/lmp.js reads and writes DOOM's demo format: v1.9, the old v1.2
header, and Chocolate Doom's longtics.npm run oracle builds Chocolate Doom 3.1.0 headless. It's unmodified:
one small file of ours, scripts/oracle/dump.c, is linked in around G_Ticker. After every
tic it writes down the random index, where the player is, how fast they're moving, their
health, and sums over every thing on the map; and, when asked, every thinker in its list.npm run lmp-fixtures makes a few demos on Freedoom's maps and records
vanilla's trace of each, in scripts/fixtures/lmp/.npm run test:lmp plays them in the port and reports the first tic each
quantity differs. A change may bring the port closer, never further (baseline.json).P_Random is DOOM's table and index. As the map is set up
the port draws what vanilla draws, in its order: each thing's P_SpawnMobj, the random tics
P_SpawnMapThing starts it with, the player's start, then the flashing and strobing lights'
first counts. The lights then run on DOOM's timers (P_SpawnLightFlash, P_SpawnStrobeFlash,
P_SpawnFireFlicker, P_SpawnGlowingLight), drawing as theirs do.P_Thrust with finesine,
friction and STOPSPEED, P_TryMove and P_CheckPosition through the map's own BLOCKMAP
lump in its order, P_SlideMove's traced slide along walls, gravity and the view's bob and
squat (P_ZMovement, P_CalcHeight), and a moving floor carrying it in the same tic.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 random index at 0. 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 SET_STATE 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 body goes through info.c's S_PLAY states on
DOOM's frame tables, as a monster does: standing (S_PLAY); told to move, running (S_PLAY_RUN1
to RUN4, 4 tics a frame, P_MovePlayer) until it stops with no move told (P_XYMovement); a shot
(S_PLAY_ATK1, and S_PLAY_ATK2, the muzzle flash, for 6 tics with the guns that flash); pain
(S_PLAY_PAIN, nearly every hit: a player's pain chance is 255, and the cry comes with its second
frame, A_Pain); dying (S_PLAY_DIE1 to DIE7, the cry in the second frame, A_PlayerScream:
in DOOM II the louder one below -50 health) or, below -100 health, gibbed (S_PLAY_XDIE1 to XDIE9).
A dead player's body can be walked over. It 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).
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:
P_DamageMobj), and ammo pickups give double
(P_GiveAmmo), weapons and backpacks included.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.ST_Responder.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.
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.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.true, which is one tic: it cancels a running invulnerability sphere.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.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.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. 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.
The simulation decides what you hear. PLAY_SOUND inserts a row into SOUND_EVENTS (sound
lump, origin, map position) for:
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-vileEach 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:
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.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.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:
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 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.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.
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.
npm install
npm run fetch-wad
npm test # the SQL smoke test
npm run test:all # every test, as many at once as there are cores
npm run serve
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 follows the pistol's, shotgun's, chaingun's and (when the WAD has
it, WAD=public/wads/freedoom2.wad) the super shotgun's fire cycles tic by tic. It fires the
rocket launcher, plasma gun, BFG and chainsaw at a monster, and checks ammo, sounds, damage,
splash and pickups.
npm run test:specials checks:
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.
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.
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.
.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.
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:
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.
Every thing and every weapon runs on DOOM's own frame tables. DOOM's state table is in Firebird
(src/states.js, see Licence, as a WAD's DEHACKED patched it), 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. It finds them through an index on the tic each state ends, and costs about 5 µs a
tic when none does. The port's own per-mode rules, which followed DOOM's movement but not its frames,
are gone (docs/ROADMAP.md, DOOM's frame tables, in phases).
Every monster lives on DOOM's states: all of DOOM I's (the zombieman, the shotgun guy, the
chaingunner, the imp, the demon, the spectre, the lost soul, the cacodemon, the Hell knight, the
baron of Hell, the cyberdemon and the spider mastermind) and all of DOOM II's (the SS guard,
Commander Keen, the pain elemental, the mancubus, the arachnotron, the revenant, the arch-vile and
the boss brain). The brain's spitter wakes as any monster does
(A_Look), cries (A_BrainAwake) and 181 tics later spits its first cube (A_BrainSpit, every other time on
skills 1 and 2) at each spawn spot in turn; the cube flies through walls, its reaction time worked out from
the y distance alone, as DOOM does ((dy / momy) / 3: a spot due east or west gets a cube that never lands),
and when that runs out A_SpawnFly makes the spawn fire and a monster, telefragging what's there on MAP30.
Killed, the brain sets off a row of 65 rockets behind it (A_BrainScream), each setting off another
(A_BrainExplode), and ends the level 120 tics later (A_BrainDie). So do the projectiles (phase 3): the imp's,
cacodemon's and baron's fireballs, rockets, plasma, the BFG's ball, the revenant's homing missile, the
mancubus's fireballs and the arachnotron's plasma fly in their spawn states' frames and burst through their
death states, as P_SpawnMissile and P_SpawnPlayerMissile start them: from the shooter, the first frame
up to 3 tics short, half a move ahead (P_CheckMissileSpawn). P_XYMovement moves them, in half moves
when they're fast (DOOM halves only positive moves, and so does this); PIT_CheckThing decides what they
hit: never their shooter, harmlessly one of its species, a solid thing that can't be shot, or something
that takes (P_Random() % 8 + 1) × the missile's damage. A wall stops them, unless a sky lies behind it,
when they're simply gone; a floor or a ceiling, sky or not, bursts them (P_ZMovement). The rocket's
A_Explode and the BFG's A_BFGSpray come in their death frames. A_Tracer steers the revenant's missile
16.875° at a time on every fourth tic, so, as in DOOM, one whose frames change on odd tics never homes:
about half of them. The effects are on the tables too: a bullet puff (P_SpawnPuff) a little up or down,
rising a unit a tic (a melee hit's without the spark), blood (P_SpawnBlood) thrown up and falling back,
starting further on for a small hit, the revenant missile's smoke trail, the teleport and item fog, the
BFG's hits, the boss brain's spawn fire, and the arch-vile's fire, which keeps in front of its victim
while the arch-vile can see it (A_Fire) and crackles; spawned in its first frame, it never calls that
frame's A_StartFire, so its start sound is never heard, as in DOOM. Things without fall
from rest at 2 units a tic, then 1 more each tic (). Barrels too: hurt without bursting, the
shooter becomes the barrel's target; burst, its blast is credited to that target, or to no one
if it went at one go ( sets none; a single-player game counts the kills anyway). And every item
and decoration plays its own frames: a bonus's glow, a torch's flicker, a dead body lying still; the things
that hang from the ceiling hang (). Last, the player's own body: standing, running,
shooting, in pain and dying in its states (see for how the others look). Every thing a map
places is on the tables now, and so are the weapons in your hands (phase 4: see ).
They start partway into their spawn state ('s ) and look
(: the gunfire heard in their sector, or the player seen in the 180° ahead), chase
(: the 45° turns, and , with its
reaction time, and ), take aim and fire (,
's one bullet, 's three pellets, the chaingunner's
bursts, the imp's : a claw within reach, else a fireball; the demons' bite; the lost soul's charge; the cacodemon's , a bite within reach, else a fireball; the barons' , a claw or a green fireball, and the baron's that opens E1M8; the cyberdemon's three rockets and its and steps; the spider mastermind's pellets and ; the mancubus's and three volleys, each of two fireballs aimed as DOOM aims them; the arachnotron's plasma and ; the pain elemental's and souls; Keen's ; the revenant's and punch and its homing missile; the arch-vile's , raising a corpse one step ahead that has room to stand, and and 's fire), and feel pain, die and get gibbed (, : 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
(, , , …). With or on Nightmare the demons' run, attack and pain states last half their tics, as
halves them: a demon steps every tic instead of every 2. The lost soul flies as DOOM's does:
sets and sends it at its target's middle at 20 units a tic; it flies on until its box
touches something solid (a slam for 3–24, then back to its spawn state) or a wall stops it, bouncing
off floors and ceilings; shot in flight it stops dead, without its pain state. Afloat, it drifts 4
units a tic towards its target's height (), as the cacodemon does once it's awake; dead, a cacodemon falls. The Hell knight cries its own DOOM II sounds (, ), not the baron's; Freedoom 1 has none, so a knight in a DOOM I PWAD is silent. (DOOM's thrust from a hit, which this port
gives no monster, would set a shot skull drifting; here it stops.) A pain elemental's souls charge
at its target the tic they appear. An old save's monsters join the table where they were. (The imp's pain cry is DOOM's ,
not the demons' .) wakes, hurts, kills and gibs them and times each
step.
What's still not vanilla, and why .lmp demos can't play yet (docs/ROADMAP.md):
finesine table; everything else's positions and speeds are doubles and its angles radians.
A monster ends up a fraction of a unit from where DOOM's would, and over a demo that adds up.P_Random is DOOM's rndtable, but its draws come in DOOM's order only
where the code follows DOOM's (the map's setup, the lights, the actions, the damage), not yet
everywhere.P_DamageMobj's thrust, for you or for
monsters. You pick things up where you end the tic, not at each spot P_TryMove tries on
the way. The page's own keys aren't quite G_BuildTiccmd's: a strafe is 25 (50 running)
rather than 24 (40), and the turn keys have no slow start.BLOCKMAP as DOOM's does; the monsters' and the
projectiles', and sight, are set-based SQL over the map's lines and things, so ties between
equally near things can break differently.PIT_ChangeSector).P_PlayerThink does (see
Weapons).
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.
firebird-wasm, Apache-2.0)wads/FREEDOOM-COPYING.txt, covering the WADs and the story text derived from them)src/states.js, with its random, sine and arctangent tables) is taken from id's source
release (GPL-2), through Chocolate Doom's info.c,
m_random.c and tables.c.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) and its number
tables (rndtable, finesine, tantoangle), generated from id's info.c, m_random.c and
tables.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).
P_ChangeSectorthe crush mover kind, CRUSH_THINGS |
P_SetMobjState, P_MobjThinker, info.c's states | SET_STATE, STATES_THINK, STATE_ACTION, the STATES table (src/states.js) |
P_SpawnMissile / P_SpawnPlayerMissile aiming, P_AimLineAttack, P_XYMovement / P_ZMovement for missiles | MONSTER_MISSILE, FIRE_MISSILE, AIM_SLOPE, MISSILES_THINK |
A_Look / A_Chase / attacks, pain, death, barrels | A_LOOK, A_CHASE, A_BULLETS, A_MELEE_MISSILE, …, DAMAGE_THING |
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 | A_TRACER, A_MELEE_MISSILE, A_FAT_ATTACK, A_VILE_TARGET, A_FIRE, A_VILE_ATTACK, A_VILE_CHASE, PAIN_SHOOT_SKULL, A_PAIN_DIE |
A_BossDeath, A_KeenDie | BOSS_DEATH, KEEN_DIE |
A_BrainSpit, A_SpawnFly, A_BrainScream (the Icon of Sin) | A_BRAIN_SPIT, A_SPAWN_FLY, A_BRAIN_SCREAM |
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 |
P_SetPsprite, P_MovePsprites, A_WeaponReady, A_Saw, A_FireShotgun2 and the other weapon actions | SET_PSPRITE, MOVE_PSPRITES, PSPRITE_ACTION (see Weapons) |
P_SpawnPlayerMissile, P_RadiusAttack, A_BFGSpray (rocket launcher, plasma gun, BFG) | FIRE_MISSILE, RADIUS_ATTACK, BFG_SPRAY; projectiles fly in MISSILES_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 |
MF_NOGRAVITYP_ZMovementA_ExplodeP_KillMobjMF_NOGRAVITYS_PLAYP_SpawnMapThing1 + P_Random() % ticsA_LookA_ChaseP_MoveP_NewChaseDirP_CheckMissileRangeMF_JUSTHITMF_JUSTATTACKEDA_FaceTargetA_PosAttackA_SPosAttackA_CPosRefireA_TroopAttackA_SargAttackA_SkullAttackA_HeadAttackA_BruisAttackA_BossDeathA_CyberAttackA_HoofA_MetalA_SpidRefireA_FatRaiseA_FatAttackA_BabyMetalA_PainAttackA_PainDieA_KeenDieA_SkelWhooshA_SkelFistA_SkelMissileA_VileChaseA_VileTargetA_VileAttackP_DamageMobjP_KillMobjA_LOOKA_CHASEA_BULLETS-fastG_InitNewA_SkullAttackMF_SKULLFLYP_ZMovementDSKNTSITDSKNTDTHDSPOPAINDSDMPAINnpm run test:states