spipu/doom-js

JavaScript

2

551 commits

updated Oct 1, 2026

See the code

README

Spipu-Doom

Doom in the browser, in pure JavaScript — no framework, no build step, no server side.

Play it: doom.spipu.net

Spipu-Doom (index.html) ships as a PWA and converts any Doom-format WAD on the fly, entirely in the browser: WAD files are stored in IndexedDB, parsed in JS (geometry, textures, doors, lifts, switches, monsters, weapons, sounds and music) and turned directly into in-memory engine objects — no server-side conversion, no generated files. Everything game-specific lives in game profiles (Doom, Freedoom, Heretic…) auto-detected from the WAD content, so other Doom-engine games plug in without touching the converter.

It runs on Spipu3D (js/engine/), the 3D engine written for it: renderer, FPS physics, entities, inputs and audio, with no knowledge of Doom whatsoever. The demos in _examples/ drive that same engine on other scenes.

Play

Nothing to download and nothing to configure — the game runs in the page.

  1. Install it — worth doing on mobile, where the browser bars otherwise eat the screen. There is no in-app install button: the browser's own menu does it.
    • iOS / iPadOS — use Safari (the option is unreliable in the other iOS browsers): tap the Share button, scroll down the sheet, Add to Home Screen, then Add. Launch it from the new icon. iOS ignores the landscape request, so turn the device to landscape yourself.
    • Android — use Chrome (Edge and Samsung Internet work too): accept the install banner if it appears, otherwise ⋮ → Install app (older versions say Add to Home screen), then Install. It comes up in landscape on its own.
    • Desktop — Chrome and Edge: the install icon in the address bar, or ⋮ → Install. Firefox has no PWA install; just play in the tab.
  2. Add a WAD. No game data ships with Spipu-Doom. Freedoom is free and BSD licensed: download it, unzip it, then load freedoom1.wad or freedoom2.wad with Local file. Add by URL takes any Doom-format WAD reachable over HTTP with CORS enabled. Doom, Doom II and Heretic WADs work the same way, if you own them.
  3. Select the WAD, then New game → episode → skill, and play.

Once it is installed and a WAD is stored, it needs no network at all: the app files are Service-Worker cached and the WAD lives in IndexedDB.

It also updates itself: every launch checks for a new version and, when there is one, re-downloads the app whole before starting — nothing to reinstall, no cache to clear by hand. Stored WADs and saved games are untouched by an update.

Run it locally

A modern browser (Chrome, Firefox, Edge) and any static HTTP server (Apache, Nginx, python3 -m http.server) — no build step, nothing server side:

cd website
python3 -m http.server 8080

Then open http://localhost:8080 and follow steps 2 and 3 above.

Features

  • WAD list: stored WADs persist in IndexedDB across sessions and updates; add one by URL or local file, delete with confirmation. Each WAD carries a SHA-256 fingerprint of its file, computed at import (at the first opening of its menu for older ones) and shown shortened in the list, by which multiplayer devices will check they play the same WAD. Mouse, keyboard, gamepad and touch drive every menu the same way.
  • WAD menu & game flow: New game, Load game, Options, About, Report a bug (opens the GitHub issues page), Quit — then the episodes actually present in the WAD and the five vanilla skills plus a pacifist skill 0 — the normal-skill world, but the monsters never attack — with the original per-skill rules.
  • Save / load: five slots per WAD; a save captures the full game state and loading rebuilds the level and restores it exactly (transient visuals excepted).
  • Pause menu: ESC freezes the game under a translucent overlay — resume, load, save, options, leave the level.
  • Death menu: a second after the player dies, over the level that keeps running — restart the level with the equipment you entered it with, start a new game, load a save, leave the level. The original's special endings (E1M8) still end the level instead.
  • Game profiles (js/doom/wad/profile/): everything game-specific — things, specials, weapons, monsters, progression, skies, sounds, HUD — is profile data, auto-detected from the WAD content. Doom, Doom II, TNT: Evilution, The Plutonia Experiment, Freedoom and Heretic are recognised and playable, each with its own level names; unknown WADs fall back to the doom-format baseline.
  • On-the-fly conversion (js/doom/wad/convert/): geometry, textures, animations and movers are instantiated directly into in-memory engine objects — no generated file, no server. Middle textures, missing textures and the known defects of the original maps are handled the way the hardware ports do.
  • Moving elements & triggers: doors (manual, remote, key-locked, timed), lifts, rising floors (a fixed-height raise lifts the floor again at each trigger, up to the ceiling, and every trigger of the same floor aims from wherever the floor and its neighbours currently are — a switch pressed on a busy floor stays unspent), stairs, perpetual platforms, moving ceilings, crushers and the donut, driven by switches, walk-over lines, gunfire, teleporters and boss deaths — behaviours verified against the original sources, including per-side and per-key door activation, and movers that crush, reopen or stall against whoever blocks them. Wall textures stay pegged as the original renders them while floors move: a floor-pegged wall or switch rides its floor, an unpegged riser keeps its texture pinned to the world.
  • World things: every non-enemy THING is a camera-facing sprite, lit by its sector and animated like the original; solid decorations block (and follow a moving floor), pickups are reached inside a vanilla-style cylinder.
  • Monsters: both complete bestiaries with the full vanilla combat loop — blood, pain, deaths and gibs, knockback, item drops, chain-exploding barrels — and the vanilla AI: wake-up by sight or sound, 8-direction chase, door opening, floaters, every attack of both games, infighting, resurrections, complete skill 5, the boss map actions and Doom II's Icon of Sin. Movers press the bodies like they press the player.
  • Player: vanilla physics (gravity, steps, jump, optional crouch and fall damage, blocking lines, and walls under the sky that no jump can clear into the void or the rock behind them), vanilla equipment rules (shared ammo pools, armor classes, keys, timed power-ups with their screen effects and HUD countdowns), and single-player thing filtering by skill.
  • Weapons: the nine Doom and eight Heretic weapons with faithful behaviour — free-aim hitscans, projectiles with fans, ballistic drops and bounces, persistent impact decals on walls, floors and ceilings (never on liquids or sky), sector-lit view sprite, muzzle flashes that briefly brighten the whole scene like the original — every table being profile data from the original sources. Switch with F/G, gamepad shoulders, or the virtual pad.
  • HUD & automap: a modern corner HUD (health, armor, ammo, keys, secrets, kills, ARMS panel) adapting to the loaded game, a debug overlay on H, an optional crosshair, and a translucent automap (Tab) over the running game, revealed like the original and titled with the level's name (the WAD's own when it carries one, else the game's).
  • Sector effects: damaging floors, floor mutations, scrolling walls, dynamic lights, distance shading, secret counting, and the Heretic pushes (wind, conveyors, ice) on player and monsters.
  • Ground terrain: the flats a game declares as liquid take no impact decal, and those it gives a splash to answer whatever reaches them — a shot, a shell, a body falling in, a blast going off close by — with the ripple, the piece thrown out of it and the sound of that liquid. Heretic's water, lava and sludge are described this way. A WAD shipping its own TERRAIN lump overrides those tables entry by entry, so a custom level can make any flat splash — or stop one from splashing.
  • Generic splash: a liquid no game describes still answers, with our own greyscale masks colourised by the average colour of the flat itself — the nukage of Doom splashes green, blood red — mirrored left or right at random, and its flying piece tilted at an angle of its own, so two impacts never stamp the same picture. Silent and always on; not a behaviour any original game had.
  • Level chaining & story texts: exits follow the vanilla progression (secret exits included, UMAPINFO overrides honoured) through a tally modal — time, enemies, items, secrets — followed by the game's own chapter texts (from the WAD when it tells its own story, else the translated catalog).
  • Sound effects: the WAD's own sounds decoded on the fly — weapons, pickups, movers, teleports, player, monsters, Heretic ambients — spatialised per game and frozen with the pause. The menus use light synthesized clicks, WAD-independent, with distinct accents for navigation, validation and cancel. Two live volume settings.
  • Music: the WAD's own songs (MUS or MIDI lumps) synthesized in real time on an OPL3 FM emulator fed with the WAD's own GENMIDI instrument bank — the original Sound Blaster sound, no external asset. Title music on the WAD menu, each level's own song in game (with the vanilla reuse rules), the intermission theme over the tally and story screens.
  • Screen sharing (in progress): the pause menu's Multiplayer entry shares the running game — a lobby of up to four players, others joining from their WAD's Multiplayer screen by scanning QR codes both ways, with no server or account; the host can add, remove and stop. Joined devices show the host's game live — view, HUD, monsters, shots, doors, sounds and effects — and follow its level changes, restarts and loaded saves; they see its pause, its death, its tally and chapter texts (in their own language) and are sent back to their menu when its game is over. A device too slow to answer is named on screen while the game waits for it, and dropped if it stays silent; one whose page goes to the background (a phone's locked screen) no longer holds the game, its player standing still, and picks up again on its return, shown as away in the lobby and announced to the others. A player whose link is lost keeps its seat until the end of the level: joining again under the same nickname gives it back its player, equipment and scores. With the fps readout on, every device also shows its ping. A joined device's touch pad keeps only the menu and the map.
  • Cooperative (in progress): the pause menu's Multiplayer entry opens the running game to other players, or turns a screen sharing into one, after a game settings screen (friendly fire); the WAD's Multiplayer screen also starts a new cooperative game, its first level already holding the multiplayer items, the lobby shown over it until Start. Each joined device then plays its own player with its full controls, dropping in at its start during the level, leaving whenever it wants, and following every level change. Every player sees the others in their slot's colour — walking, shooting, hurt, dying, squashed when crouching — and on the map as coloured arrows; the lobby shows the colours, and a player who leaves is named on screen for a moment. A dead player respawns at its start by pressing use, leaving its corpse behind; placed weapons and keys stay on the ground for everyone. The tally gives each player's kills, items and secrets, and the host saves and loads the game, the other players following it.
  • Deathmatch (in progress): the WAD's Multiplayer screen starts a new deathmatch through the same episode, difficulty, game settings and lobby screens. Every player enters and respawns on a random deathmatch start, holding every key while the map's keys stay out; monsters are there or not as the settings say, every attack hurts the others, and there is no save, load or cheat. The HUD scores the frags — one's own first, then each other player's in its colour (a death by nothing counts against oneself) — and the map never shows the other players. The game settings choose between weapons that stay on the ground (giving extra ammo in Doom) and items that all come back a while after being taken, in a fog and with a sound, except the strongest power-ups. A frag limit or a time limit ends the level, whose tally shows every player's frags against every other; each level starts once every player has built it, and the match ends for everyone on the last level, or for the host once nobody is left to play against.
  • Options & persistent settings: Display, Game, Multiplayer, Sound and Controls pages — full keyboard remapping included, one key per action — persisted in IndexedDB, with a confirmed reset. The Multiplayer page, offered from a WAD's menu and from the pause's Multiplayer entry, holds the cooperative and deathmatch game settings and the player's nickname, typed on an on-screen keyboard laid out like the interface language's (AZERTY in French, QWERTY otherwise) and walked with the mouse, touch, arrows or gamepad, or on the physical keyboard — never through the OS keyboard.
  • Renderer choice: the Display page picks one of the four rendering modes (see The 3D engine below), WebGL by default. A change applies to the running level without reloading it: the screen, the engine and the HUD are rebuilt on the next live frame, the level and the player carry on untouched.
  • Inputs: keyboard+mouse, gamepad (press a button to activate it), or a touch virtual gamepad laid out for a 4-finger claw grip, with per-gesture dead zones and firing sensitivity. The devices never reach the simulation directly: a command sampler turns them into one plain-data command per turn — movement axes, look angles in degrees, named buttons (the game's fire, weapon switch and full-kit cheat included), the weapon wheel steps — which the world and the game consume.
  • Translation (en / fr / it / es): every user-facing text goes through a translation catalog addressed by code, the finale texts included; locale-dependent formats go through Intl.
  • Robustness: a failed level build reports its cause and returns to the WAD list; a stored setting that no longer matches what its declaration allows is repaired at startup (a text setting keeps what its sanitising accepts, any other falls back to its default); every menu screen shows the aggregated version, the webapp stats and the copyright.

Controls

Keyboard defaults below are physical key positions (WASD = ZQSD on an AZERTY layout) and every one of them can be remapped in the Options modal (from a WAD's menu), one key per action — except ESC, the fixed pause key. The lights, game and world demos share this input stack, at the default keys: they answer to the gamepad and to the touch pad the same way, each keeping only the controls it has a use for.

Keyboard / mouseGamepadAction
WASDLeft stickMove / strafe (analog on the stick)
Mouse (click canvas first)Right stickLook around
Left ShiftButton 1Jump
Left CtrlButton 0Crouch — careful: holding it with the key that types q is Ctrl+Q, which quits Firefox (a browser-privileged shortcut); remap if it bites you
EButton 3Interact (open door, trigger lift or switch)
Left click / QButton 2 / right triggerFire the active weapon
ESCButton 9Pause menu over the frozen game (not remappable)
F / GButtons 4 / 5Previous / next weapon (wrapping)
H—Toggle the game HUD ↔ debug overlay (keyboard only)
TabD-pad upShow / hide the automap over the game
Left Alt—Walk slowly (sticks do it through partial deflection)
IJKL—Look around — keyboard fallback when the mouse / Pointer Lock is unavailable
O—Debug cheat (not remappable): grant the full kit

The gamepad is only visible to the page after a button has been pressed on it (browser privacy rule); it then takes priority over keyboard+mouse. Touch-only devices select the virtual gamepad (see Inputs above). On iOS the touch mapping and the menus stay aligned with the display across device rotation.

The Spipu3D engine

js/engine/ is a standalone 3D engine with no external dependency: it renders textured, lit 3D objects entirely in the browser through the HTML5 <canvas> API, and carries a full FPS physics engine (collision detection, gravity, jumping, crouching, animated objects) for one or several users sharing a world, each moved by its own command and blocking the others. It never depends on js/doom/: it exposes parameterisable primitives (depth shading, per-instance light and render offset, external forces, screen sprites…) that the game layer feeds with its own constants.

Four rendering modes are available, selectable in the game from the Display options page and via the Renderer selector on _examples/objects.html:

ModeDescription
webglWebGL — GPU shaders, z-buffer, texture mapping (default, falls back to full if unavailable)
fullPer-pixel z-buffer with Gouraud shading and texture mapping
flatPainter's algorithm with flat shading — one colour per face, the average colour of its texture dimmed by the sector light — on a neutral grey backdrop without sky
fastWireframe — no lighting, canvas 2D paths only, on the same grey backdrop

In the two textureless modes the game paints enemies red, every pickup blue — keys included, and the exploding barrel counts as a body — every moving part (doors, lifts, floors, stairs) yellow and the switches magenta, so a scene stays readable without its sprites. Both draw the whole frame in one depth-sorted pass, so a body behind a wall stays behind it instead of being painted over the map.

Whatever the mode, instances are frustum-culled in camera space before any per-vertex work; the static level map is one single object, always drawn whole.

Demo pages

PageDescription
_examples/index.htmlHome page — links to all demos
_examples/objects.htmlObject viewer — pick an object, resolution and renderer
_examples/example.htmlStatic render of the Lotus F1
_examples/lights.htmlColoured light sources demo — move them around
_examples/game.htmlInteractive van — drive it
_examples/world.htmlFirst-person navigation inside a 3D labyrinth
_examples/pairing-test.htmlNetwork test bench — devices pair by QR code (or two tabs in loopback), link over WebRTC, measure their ping and exchange chunked bursts
_examples/pairing-game.htmlNetwork game — the main drives the van through the synchronous turn cycle (a state to every sub, a command back from each, then the next turn), every paired sub watches it live with its ping and the turn rate

Lights, game and world run in a 16:9 letterboxed fullscreen and take keyboard, gamepad or touch pad, exactly like the game.

Demo objects (cube, sphere, lotus, van…) and the labyrinth world live in _examples/assets/.

Architecture

The static collision geometry is indexed once per level in a uniform XZ spatial grid, so every floor/wall/ray query only tests the triangles of the cells it touches. Dynamic movers stay on a linear scan.

website/
├── index.html                Spipu-Doom shell (PWA)
├── appServiceWorker.js       Service Worker — cache-first, offline (must stay at webroot: SW scope)
├── ping.json                 Install/update/start tracking json — hit only
├── css/                      Shell + menu styles
├── assets/uzdoom/            UZDoom impact-decal graphics + finale texts (GPL v3 — own LICENSE.md + README.md)
├── assets/spipu/             Our own graphics: the generic splash masks, colourised at level load
├── _examples/                Spipu3D demos + their assets and bootstrap definitions
└── js/
    ├── webapp/               Generic webapp layer — bootstrap/versioning, IndexedDB wrapper, translation catalog, content hash, wake lock
    │   ├── net/                 Peer-to-peer network layer — WebRTC and loopback links, compact signals, pairing codes and flows, messages, ping, star sessions
    │   └── qr/                  QR code writing, camera scanning, camera probe and the pairing view (code shown, code read)
    ├── lib/libadlmidi/       Vendored libADLMIDI-JS OPL3 synthesizer (LGPL v3 — own LICENSE.md + modification README.md)
    ├── lib/zxing-wasm/       Vendored zxing-wasm QR code reader and writer (MIT, Apache-2.0, BSD-3 — own LICENSE.md + README.md)
    ├── doom/                 The Spipu-Doom game
    │   ├── libBootstrap.json    Doom bootstrap definition (version + file lists)
    │   ├── doomGame.js          Level lifecycle, game loop, menus of the running game, tally
    │   ├── doomUser.js          Player equipment state
    │   ├── doomPlayer.js        One player across its levels: its body, its weapon, the equipment it enters and carries
    │   ├── doomPlayerBody.js    A player's visible body in cooperative, as the others see it
    │   ├── doomInertInstance.js An instance born in play that nothing touches (body replica, shot, effect, decal)
    │   ├── doomSessionNotice.js The message a session shows over the game (waiting, host paused, respawn prompt, player left)
    │   ├── doomPlayerRoster.js  The players of a game, the local one among them
    │   ├── doomLevelLoader.js   The level build every device runs: the converted world and the visual banks it shows
    │   ├── doomSimulation.js    The level's world and the tic that moves it from one command per player: monsters, projectiles, saves
    │   ├── doomMainRole.js      The role of the device that simulates: the simulation and what only that device does
    │   ├── doomSubRole.js       The role of a device that follows another's game: applies each turn state and answers it
    │   ├── doomLevelStats.js    The level's statistics: secrets, kills and items found against their totals, per player too, the frags, level time
    │   ├── doomTurnEvents.js    Every one-shot event of a turn (sounds, effects, decals, teleports), emitted by the simulation
    │   ├── doomTurnEventPlayer.js  How a device plays those events
    │   ├── doomItemRules.js     What pickups, the starting loadout and the cheat give to a player
    │   ├── doomPickupSpawner.js  Brings a map pickup back into the level, the same on every device
    │   ├── doomItemRespawnQueue.js  The deathmatch items waiting to come back
    │   ├── doomGameRules.js     The mode-dependent questions (things built, spawn spots and keys, death menu or respawn, saves, cheat, screen sharing, cooperative, items staying, friendly fire, frags, players on the map)
    │   ├── doomSinglePlayerRules.js  Their single-player answers
    │   ├── doomCoopRules.js     Their cooperative answers
    │   ├── doomDeathmatchRules.js  Their deathmatch answers
    │   ├── doomPresentation.js  What a device shows of the game through one player: screen, HUD, weapon overlay, view effects, sound
    │   ├── doomSettings.js      Persistent settings (IndexedDB)
    │   ├── doomTranslations.js  Every user-facing text (en + fr + it + es)
    │   ├── doomFinaleTexts.js   Finale-text catalogs of the games (loaded from assets/)
    │   ├── doomImageAssets.js   Source pixels of every PNG drawn from outside the WAD (decals, splash masks)
    │   ├── main.js              Entry point
    │   ├── save/                Save slots + level snapshot (deterministic rebuild + state patch)
    │   ├── sound/               Game audio: WAD sound loading, logical-name catalog (profile SNDINFO tables), music orchestration
    │   ├── object/              Immutable definitions (weapons, ammo, items, decorations, thing and item catalogs)
    │   ├── monster/             Monster system: defs, 35 Hz driver, locomotion, senses, attacks, damage, boss deaths, the Icon of Sin, and the drawable body views with their renderer
    │   ├── automap/             Level map: line model, state, and the vanilla BSP reveal
    │   ├── hud/                 Game HUD + debug overlay + automap layer
    │   ├── menu/                DOM menu screens and modals (WAD list, episodes, multiplayer, options, text entry, pause and its multiplayer sub-menu, death, save slots, pairing, lobby), and their keyboard / gamepad navigation
    │   ├── net/                 Multiplayer of the game: pairing payload, lobby, main and sub sessions, device availability, the replicated turn state and commands with their binary codecs, the host's turn cycle and the replica applier
    │   ├── weapon/              Weapon machinery: psprite machine, hitscan, projectiles, effects, decals, and the drawable weapon and projectile views with their renderers
    │   └── wad/                 WAD reading + IndexedDB storage, game profiles (profile/), on-the-fly converter (convert/)
    └── engine/               Spipu3D — the game-agnostic 3D engine
        ├── libBootstrap.json    Engine bootstrap definition (version + file lists)
        ├── engine3d.js          Viewport, lights, render loop, frustum culling
        ├── collision.js         FPS physics: spatially indexed triangles, box blockers, mover pressure
        ├── spatialGrid.js       Uniform XZ grid over a static triangle set
        ├── entity/              Object3d, Billboard, Instance (keyframes/triggers/cycles), User, UserCommand, World, external forces
        ├── input/               Unified inputs: keyboard, mouse, gamepad, virtual touch gamepad, and the command sampler
        ├── interaction/         Interaction bases (switch modes once/timed/toggle)
        ├── loader/              URL or in-memory loaders (textures, objects, instances, interactions, world)
        ├── sound/               Audio primitives: shared AudioContext with music/effects buses, in-memory PCM samples, tone synthesis, music player over a swappable synth contract
        ├── hud/                 HUD bases (debug overlay, screen flash)
        └── renderer/            webgl / full / flat / fast renderers

In-memory loading

The engine loaders accept either URLs (classic flow, used by the demo pages) or in-memory data (used by the WAD converter):

loader.reset();
loader.beginBatch();                                    // suspends the global finalize check
const texId = loader.textures().loadFromData(null, imageData);          // ImageData
const objId = loader.objects().loadFromData('map', {textures, points, faces});
loader.instances().loadFromData(null, {...instanceData, object: objId});
loader.interactions().loadFromData(new DoomSwitchInteraction(...));
loader.world().loadFromData(definition);                // user, background, lights
loader.setCallback(init);
loader.endBatch();                                      // finalizes everything once, fires init

Webapp bootstrap

Every page is loaded by the generic appBootstrap (global instance). Each library declares its files in a libBootstrap.json definition ({version, files: {assets, css, js}}); pages stack the definitions they need and register their entry point:

<div id="screen"></div>
<script src="/js/webapp/appBootstrap.js"></script>
<script>
function loadApp()
{
    loader.world().load('./assets/world/definition.json');
    loader.setCallback(init);
}

appBootstrap.disablePwaMode();                                       // demos only — doom keeps PWA mode
appBootstrap.addBootstrapDefinition('/js/engine/libBootstrap.json');
appBootstrap.addBootstrapDefinition('./assets/world.json');
appBootstrap.setReadyCallback(loadApp);
</script>

Versions are aggregated (v2.001|v1.018): a change in any stacked definition triggers a full update — in PWA mode the Service Worker clears its cache and re-downloads everything; in classic mode the page reloads with ?v= cache-busted URLs (appBootstrap.buildUrl).

Page pattern

function init() {
    const world = loader.world().get();
    screen = new ScreenManager('screen', { fullscreen: true });  // or { width, height }
                                                                 // or { fullscreen: true, virtualWidth: 1920, virtualHeight: 1080 }
    inputs  = new Inputs().bindScreen(screen);  // one single instance per page, rebound on each level
    sampler = new InputCommandSampler(inputs);  // the devices, turned into one command per turn
    engine = new Engine3d(screen, new Object3dRendererList().getRenderer('webgl'));  // binds itself to the screen
    const hud = new HudDebug(engine)
        .bindUser(world.getUser()).bindInputs(inputs)
        .addDescription('(c)2026 Spipu')
    ;
    screen.bindHud(hud);
    engine.initFromWorld(world);
    requestAnimationFrame(animate);
}

function animate(timestamp) {
    engine.calculateDeltaTime(timestamp);
    const dt = engine.getDeltaTime();
    world.update(dt, new Map([[world.getUser(), sampler.collect(dt).sample()]]));  // one command per user
    engine.displayWorld(world, world.getUser());  // the camera looks through one user
    screen.update(); // updates HUD overlay
    requestAnimationFrame(animate);
}

Versioning

After any file change, increment the version field of the libBootstrap.json of the modified library (engine, doom, or the demo's definition JSON). This drives both the PWA cache refresh and the classic-mode cache busting.

Next steps

The upcoming work is tracked in ./NEXT-STEPS.md.

License

This program is distributed under the MIT License — see the ./LICENSE.md file, except:

  • the website/assets/uzdoom/ directory (impact-decal graphics and finale texts taken from UZDoom), distributed under the GPL v3, with its own LICENSE.md and attribution README.
  • the website/js/lib/libadlmidi/ directory (the vendored libADLMIDI-JS music synthesizer), distributed under the LGPL v3, with its own LICENSE.md and attribution README.
  • the website/js/lib/zxing-wasm/ directory (the vendored QR code library), distributed under the MIT, Apache-2.0 and BSD-3-Clause licences of its three components, all permissive, with its own LICENSE.md.

spipu/doom-js

JavaScript

2

551 commits

updated Oct 1, 2026

See the code

README

Spipu-Doom

Doom in the browser, in pure JavaScript — no framework, no build step, no server side.

Play it: doom.spipu.net

Spipu-Doom (index.html) ships as a PWA and converts any Doom-format WAD on the fly, entirely in the browser: WAD files are stored in IndexedDB, parsed in JS (geometry, textures, doors, lifts, switches, monsters, weapons, sounds and music) and turned directly into in-memory engine objects — no server-side conversion, no generated files. Everything game-specific lives in game profiles (Doom, Freedoom, Heretic…) auto-detected from the WAD content, so other Doom-engine games plug in without touching the converter.

It runs on Spipu3D (js/engine/), the 3D engine written for it: renderer, FPS physics, entities, inputs and audio, with no knowledge of Doom whatsoever. The demos in _examples/ drive that same engine on other scenes.

Play

Nothing to download and nothing to configure — the game runs in the page.

  1. Install it — worth doing on mobile, where the browser bars otherwise eat the screen. There is no in-app install button: the browser's own menu does it.
    • iOS / iPadOS — use Safari (the option is unreliable in the other iOS browsers): tap the Share button, scroll down the sheet, Add to Home Screen, then Add. Launch it from the new icon. iOS ignores the landscape request, so turn the device to landscape yourself.
    • Android — use Chrome (Edge and Samsung Internet work too): accept the install banner if it appears, otherwise ⋮ → Install app (older versions say Add to Home screen), then Install. It comes up in landscape on its own.
    • Desktop — Chrome and Edge: the install icon in the address bar, or ⋮ → Install. Firefox has no PWA install; just play in the tab.
  2. Add a WAD. No game data ships with Spipu-Doom. Freedoom is free and BSD licensed: download it, unzip it, then load freedoom1.wad or freedoom2.wad with Local file. Add by URL takes any Doom-format WAD reachable over HTTP with CORS enabled. Doom, Doom II and Heretic WADs work the same way, if you own them.
  3. Select the WAD, then New game → episode → skill, and play.

Once it is installed and a WAD is stored, it needs no network at all: the app files are Service-Worker cached and the WAD lives in IndexedDB.

It also updates itself: every launch checks for a new version and, when there is one, re-downloads the app whole before starting — nothing to reinstall, no cache to clear by hand. Stored WADs and saved games are untouched by an update.

Run it locally

A modern browser (Chrome, Firefox, Edge) and any static HTTP server (Apache, Nginx, python3 -m http.server) — no build step, nothing server side:

cd website
python3 -m http.server 8080

Then open http://localhost:8080 and follow steps 2 and 3 above.

Features

  • WAD list: stored WADs persist in IndexedDB across sessions and updates; add one by URL or local file, delete with confirmation. Each WAD carries a SHA-256 fingerprint of its file, computed at import (at the first opening of its menu for older ones) and shown shortened in the list, by which multiplayer devices will check they play the same WAD. Mouse, keyboard, gamepad and touch drive every menu the same way.
  • WAD menu & game flow: New game, Load game, Options, About, Report a bug (opens the GitHub issues page), Quit — then the episodes actually present in the WAD and the five vanilla skills plus a pacifist skill 0 — the normal-skill world, but the monsters never attack — with the original per-skill rules.
  • Save / load: five slots per WAD; a save captures the full game state and loading rebuilds the level and restores it exactly (transient visuals excepted).
  • Pause menu: ESC freezes the game under a translucent overlay — resume, load, save, options, leave the level.
  • Death menu: a second after the player dies, over the level that keeps running — restart the level with the equipment you entered it with, start a new game, load a save, leave the level. The original's special endings (E1M8) still end the level instead.
  • Game profiles (js/doom/wad/profile/): everything game-specific — things, specials, weapons, monsters, progression, skies, sounds, HUD — is profile data, auto-detected from the WAD content. Doom, Doom II, TNT: Evilution, The Plutonia Experiment, Freedoom and Heretic are recognised and playable, each with its own level names; unknown WADs fall back to the doom-format baseline.
  • On-the-fly conversion (js/doom/wad/convert/): geometry, textures, animations and movers are instantiated directly into in-memory engine objects — no generated file, no server. Middle textures, missing textures and the known defects of the original maps are handled the way the hardware ports do.
  • Moving elements & triggers: doors (manual, remote, key-locked, timed), lifts, rising floors (a fixed-height raise lifts the floor again at each trigger, up to the ceiling, and every trigger of the same floor aims from wherever the floor and its neighbours currently are — a switch pressed on a busy floor stays unspent), stairs, perpetual platforms, moving ceilings, crushers and the donut, driven by switches, walk-over lines, gunfire, teleporters and boss deaths — behaviours verified against the original sources, including per-side and per-key door activation, and movers that crush, reopen or stall against whoever blocks them. Wall textures stay pegged as the original renders them while floors move: a floor-pegged wall or switch rides its floor, an unpegged riser keeps its texture pinned to the world.
  • World things: every non-enemy THING is a camera-facing sprite, lit by its sector and animated like the original; solid decorations block (and follow a moving floor), pickups are reached inside a vanilla-style cylinder.
  • Monsters: both complete bestiaries with the full vanilla combat loop — blood, pain, deaths and gibs, knockback, item drops, chain-exploding barrels — and the vanilla AI: wake-up by sight or sound, 8-direction chase, door opening, floaters, every attack of both games, infighting, resurrections, complete skill 5, the boss map actions and Doom II's Icon of Sin. Movers press the bodies like they press the player.
  • Player: vanilla physics (gravity, steps, jump, optional crouch and fall damage, blocking lines, and walls under the sky that no jump can clear into the void or the rock behind them), vanilla equipment rules (shared ammo pools, armor classes, keys, timed power-ups with their screen effects and HUD countdowns), and single-player thing filtering by skill.
  • Weapons: the nine Doom and eight Heretic weapons with faithful behaviour — free-aim hitscans, projectiles with fans, ballistic drops and bounces, persistent impact decals on walls, floors and ceilings (never on liquids or sky), sector-lit view sprite, muzzle flashes that briefly brighten the whole scene like the original — every table being profile data from the original sources. Switch with F/G, gamepad shoulders, or the virtual pad.
  • HUD & automap: a modern corner HUD (health, armor, ammo, keys, secrets, kills, ARMS panel) adapting to the loaded game, a debug overlay on H, an optional crosshair, and a translucent automap (Tab) over the running game, revealed like the original and titled with the level's name (the WAD's own when it carries one, else the game's).
  • Sector effects: damaging floors, floor mutations, scrolling walls, dynamic lights, distance shading, secret counting, and the Heretic pushes (wind, conveyors, ice) on player and monsters.
  • Ground terrain: the flats a game declares as liquid take no impact decal, and those it gives a splash to answer whatever reaches them — a shot, a shell, a body falling in, a blast going off close by — with the ripple, the piece thrown out of it and the sound of that liquid. Heretic's water, lava and sludge are described this way. A WAD shipping its own TERRAIN lump overrides those tables entry by entry, so a custom level can make any flat splash — or stop one from splashing.
  • Generic splash: a liquid no game describes still answers, with our own greyscale masks colourised by the average colour of the flat itself — the nukage of Doom splashes green, blood red — mirrored left or right at random, and its flying piece tilted at an angle of its own, so two impacts never stamp the same picture. Silent and always on; not a behaviour any original game had.
  • Level chaining & story texts: exits follow the vanilla progression (secret exits included, UMAPINFO overrides honoured) through a tally modal — time, enemies, items, secrets — followed by the game's own chapter texts (from the WAD when it tells its own story, else the translated catalog).
  • Sound effects: the WAD's own sounds decoded on the fly — weapons, pickups, movers, teleports, player, monsters, Heretic ambients — spatialised per game and frozen with the pause. The menus use light synthesized clicks, WAD-independent, with distinct accents for navigation, validation and cancel. Two live volume settings.
  • Music: the WAD's own songs (MUS or MIDI lumps) synthesized in real time on an OPL3 FM emulator fed with the WAD's own GENMIDI instrument bank — the original Sound Blaster sound, no external asset. Title music on the WAD menu, each level's own song in game (with the vanilla reuse rules), the intermission theme over the tally and story screens.
  • Screen sharing (in progress): the pause menu's Multiplayer entry shares the running game — a lobby of up to four players, others joining from their WAD's Multiplayer screen by scanning QR codes both ways, with no server or account; the host can add, remove and stop. Joined devices show the host's game live — view, HUD, monsters, shots, doors, sounds and effects — and follow its level changes, restarts and loaded saves; they see its pause, its death, its tally and chapter texts (in their own language) and are sent back to their menu when its game is over. A device too slow to answer is named on screen while the game waits for it, and dropped if it stays silent; one whose page goes to the background (a phone's locked screen) no longer holds the game, its player standing still, and picks up again on its return, shown as away in the lobby and announced to the others. A player whose link is lost keeps its seat until the end of the level: joining again under the same nickname gives it back its player, equipment and scores. With the fps readout on, every device also shows its ping. A joined device's touch pad keeps only the menu and the map.
  • Cooperative (in progress): the pause menu's Multiplayer entry opens the running game to other players, or turns a screen sharing into one, after a game settings screen (friendly fire); the WAD's Multiplayer screen also starts a new cooperative game, its first level already holding the multiplayer items, the lobby shown over it until Start. Each joined device then plays its own player with its full controls, dropping in at its start during the level, leaving whenever it wants, and following every level change. Every player sees the others in their slot's colour — walking, shooting, hurt, dying, squashed when crouching — and on the map as coloured arrows; the lobby shows the colours, and a player who leaves is named on screen for a moment. A dead player respawns at its start by pressing use, leaving its corpse behind; placed weapons and keys stay on the ground for everyone. The tally gives each player's kills, items and secrets, and the host saves and loads the game, the other players following it.
  • Deathmatch (in progress): the WAD's Multiplayer screen starts a new deathmatch through the same episode, difficulty, game settings and lobby screens. Every player enters and respawns on a random deathmatch start, holding every key while the map's keys stay out; monsters are there or not as the settings say, every attack hurts the others, and there is no save, load or cheat. The HUD scores the frags — one's own first, then each other player's in its colour (a death by nothing counts against oneself) — and the map never shows the other players. The game settings choose between weapons that stay on the ground (giving extra ammo in Doom) and items that all come back a while after being taken, in a fog and with a sound, except the strongest power-ups. A frag limit or a time limit ends the level, whose tally shows every player's frags against every other; each level starts once every player has built it, and the match ends for everyone on the last level, or for the host once nobody is left to play against.
  • Options & persistent settings: Display, Game, Multiplayer, Sound and Controls pages — full keyboard remapping included, one key per action — persisted in IndexedDB, with a confirmed reset. The Multiplayer page, offered from a WAD's menu and from the pause's Multiplayer entry, holds the cooperative and deathmatch game settings and the player's nickname, typed on an on-screen keyboard laid out like the interface language's (AZERTY in French, QWERTY otherwise) and walked with the mouse, touch, arrows or gamepad, or on the physical keyboard — never through the OS keyboard.
  • Renderer choice: the Display page picks one of the four rendering modes (see The 3D engine below), WebGL by default. A change applies to the running level without reloading it: the screen, the engine and the HUD are rebuilt on the next live frame, the level and the player carry on untouched.
  • Inputs: keyboard+mouse, gamepad (press a button to activate it), or a touch virtual gamepad laid out for a 4-finger claw grip, with per-gesture dead zones and firing sensitivity. The devices never reach the simulation directly: a command sampler turns them into one plain-data command per turn — movement axes, look angles in degrees, named buttons (the game's fire, weapon switch and full-kit cheat included), the weapon wheel steps — which the world and the game consume.
  • Translation (en / fr / it / es): every user-facing text goes through a translation catalog addressed by code, the finale texts included; locale-dependent formats go through Intl.
  • Robustness: a failed level build reports its cause and returns to the WAD list; a stored setting that no longer matches what its declaration allows is repaired at startup (a text setting keeps what its sanitising accepts, any other falls back to its default); every menu screen shows the aggregated version, the webapp stats and the copyright.

Controls

Keyboard defaults below are physical key positions (WASD = ZQSD on an AZERTY layout) and every one of them can be remapped in the Options modal (from a WAD's menu), one key per action — except ESC, the fixed pause key. The lights, game and world demos share this input stack, at the default keys: they answer to the gamepad and to the touch pad the same way, each keeping only the controls it has a use for.

Keyboard / mouseGamepadAction
WASDLeft stickMove / strafe (analog on the stick)
Mouse (click canvas first)Right stickLook around
Left ShiftButton 1Jump
Left CtrlButton 0Crouch — careful: holding it with the key that types q is Ctrl+Q, which quits Firefox (a browser-privileged shortcut); remap if it bites you
EButton 3Interact (open door, trigger lift or switch)
Left click / QButton 2 / right triggerFire the active weapon
ESCButton 9Pause menu over the frozen game (not remappable)
F / GButtons 4 / 5Previous / next weapon (wrapping)
H—Toggle the game HUD ↔ debug overlay (keyboard only)
TabD-pad upShow / hide the automap over the game
Left Alt—Walk slowly (sticks do it through partial deflection)
IJKL—Look around — keyboard fallback when the mouse / Pointer Lock is unavailable
O—Debug cheat (not remappable): grant the full kit

The gamepad is only visible to the page after a button has been pressed on it (browser privacy rule); it then takes priority over keyboard+mouse. Touch-only devices select the virtual gamepad (see Inputs above). On iOS the touch mapping and the menus stay aligned with the display across device rotation.

The Spipu3D engine

js/engine/ is a standalone 3D engine with no external dependency: it renders textured, lit 3D objects entirely in the browser through the HTML5 <canvas> API, and carries a full FPS physics engine (collision detection, gravity, jumping, crouching, animated objects) for one or several users sharing a world, each moved by its own command and blocking the others. It never depends on js/doom/: it exposes parameterisable primitives (depth shading, per-instance light and render offset, external forces, screen sprites…) that the game layer feeds with its own constants.

Four rendering modes are available, selectable in the game from the Display options page and via the Renderer selector on _examples/objects.html:

ModeDescription
webglWebGL — GPU shaders, z-buffer, texture mapping (default, falls back to full if unavailable)
fullPer-pixel z-buffer with Gouraud shading and texture mapping
flatPainter's algorithm with flat shading — one colour per face, the average colour of its texture dimmed by the sector light — on a neutral grey backdrop without sky
fastWireframe — no lighting, canvas 2D paths only, on the same grey backdrop

In the two textureless modes the game paints enemies red, every pickup blue — keys included, and the exploding barrel counts as a body — every moving part (doors, lifts, floors, stairs) yellow and the switches magenta, so a scene stays readable without its sprites. Both draw the whole frame in one depth-sorted pass, so a body behind a wall stays behind it instead of being painted over the map.

Whatever the mode, instances are frustum-culled in camera space before any per-vertex work; the static level map is one single object, always drawn whole.

Demo pages

PageDescription
_examples/index.htmlHome page — links to all demos
_examples/objects.htmlObject viewer — pick an object, resolution and renderer
_examples/example.htmlStatic render of the Lotus F1
_examples/lights.htmlColoured light sources demo — move them around
_examples/game.htmlInteractive van — drive it
_examples/world.htmlFirst-person navigation inside a 3D labyrinth
_examples/pairing-test.htmlNetwork test bench — devices pair by QR code (or two tabs in loopback), link over WebRTC, measure their ping and exchange chunked bursts
_examples/pairing-game.htmlNetwork game — the main drives the van through the synchronous turn cycle (a state to every sub, a command back from each, then the next turn), every paired sub watches it live with its ping and the turn rate

Lights, game and world run in a 16:9 letterboxed fullscreen and take keyboard, gamepad or touch pad, exactly like the game.

Demo objects (cube, sphere, lotus, van…) and the labyrinth world live in _examples/assets/.

Architecture

The static collision geometry is indexed once per level in a uniform XZ spatial grid, so every floor/wall/ray query only tests the triangles of the cells it touches. Dynamic movers stay on a linear scan.

website/
├── index.html                Spipu-Doom shell (PWA)
├── appServiceWorker.js       Service Worker — cache-first, offline (must stay at webroot: SW scope)
├── ping.json                 Install/update/start tracking json — hit only
├── css/                      Shell + menu styles
├── assets/uzdoom/            UZDoom impact-decal graphics + finale texts (GPL v3 — own LICENSE.md + README.md)
├── assets/spipu/             Our own graphics: the generic splash masks, colourised at level load
├── _examples/                Spipu3D demos + their assets and bootstrap definitions
└── js/
    ├── webapp/               Generic webapp layer — bootstrap/versioning, IndexedDB wrapper, translation catalog, content hash, wake lock
    │   ├── net/                 Peer-to-peer network layer — WebRTC and loopback links, compact signals, pairing codes and flows, messages, ping, star sessions
    │   └── qr/                  QR code writing, camera scanning, camera probe and the pairing view (code shown, code read)
    ├── lib/libadlmidi/       Vendored libADLMIDI-JS OPL3 synthesizer (LGPL v3 — own LICENSE.md + modification README.md)
    ├── lib/zxing-wasm/       Vendored zxing-wasm QR code reader and writer (MIT, Apache-2.0, BSD-3 — own LICENSE.md + README.md)
    ├── doom/                 The Spipu-Doom game
    │   ├── libBootstrap.json    Doom bootstrap definition (version + file lists)
    │   ├── doomGame.js          Level lifecycle, game loop, menus of the running game, tally
    │   ├── doomUser.js          Player equipment state
    │   ├── doomPlayer.js        One player across its levels: its body, its weapon, the equipment it enters and carries
    │   ├── doomPlayerBody.js    A player's visible body in cooperative, as the others see it
    │   ├── doomInertInstance.js An instance born in play that nothing touches (body replica, shot, effect, decal)
    │   ├── doomSessionNotice.js The message a session shows over the game (waiting, host paused, respawn prompt, player left)
    │   ├── doomPlayerRoster.js  The players of a game, the local one among them
    │   ├── doomLevelLoader.js   The level build every device runs: the converted world and the visual banks it shows
    │   ├── doomSimulation.js    The level's world and the tic that moves it from one command per player: monsters, projectiles, saves
    │   ├── doomMainRole.js      The role of the device that simulates: the simulation and what only that device does
    │   ├── doomSubRole.js       The role of a device that follows another's game: applies each turn state and answers it
    │   ├── doomLevelStats.js    The level's statistics: secrets, kills and items found against their totals, per player too, the frags, level time
    │   ├── doomTurnEvents.js    Every one-shot event of a turn (sounds, effects, decals, teleports), emitted by the simulation
    │   ├── doomTurnEventPlayer.js  How a device plays those events
    │   ├── doomItemRules.js     What pickups, the starting loadout and the cheat give to a player
    │   ├── doomPickupSpawner.js  Brings a map pickup back into the level, the same on every device
    │   ├── doomItemRespawnQueue.js  The deathmatch items waiting to come back
    │   ├── doomGameRules.js     The mode-dependent questions (things built, spawn spots and keys, death menu or respawn, saves, cheat, screen sharing, cooperative, items staying, friendly fire, frags, players on the map)
    │   ├── doomSinglePlayerRules.js  Their single-player answers
    │   ├── doomCoopRules.js     Their cooperative answers
    │   ├── doomDeathmatchRules.js  Their deathmatch answers
    │   ├── doomPresentation.js  What a device shows of the game through one player: screen, HUD, weapon overlay, view effects, sound
    │   ├── doomSettings.js      Persistent settings (IndexedDB)
    │   ├── doomTranslations.js  Every user-facing text (en + fr + it + es)
    │   ├── doomFinaleTexts.js   Finale-text catalogs of the games (loaded from assets/)
    │   ├── doomImageAssets.js   Source pixels of every PNG drawn from outside the WAD (decals, splash masks)
    │   ├── main.js              Entry point
    │   ├── save/                Save slots + level snapshot (deterministic rebuild + state patch)
    │   ├── sound/               Game audio: WAD sound loading, logical-name catalog (profile SNDINFO tables), music orchestration
    │   ├── object/              Immutable definitions (weapons, ammo, items, decorations, thing and item catalogs)
    │   ├── monster/             Monster system: defs, 35 Hz driver, locomotion, senses, attacks, damage, boss deaths, the Icon of Sin, and the drawable body views with their renderer
    │   ├── automap/             Level map: line model, state, and the vanilla BSP reveal
    │   ├── hud/                 Game HUD + debug overlay + automap layer
    │   ├── menu/                DOM menu screens and modals (WAD list, episodes, multiplayer, options, text entry, pause and its multiplayer sub-menu, death, save slots, pairing, lobby), and their keyboard / gamepad navigation
    │   ├── net/                 Multiplayer of the game: pairing payload, lobby, main and sub sessions, device availability, the replicated turn state and commands with their binary codecs, the host's turn cycle and the replica applier
    │   ├── weapon/              Weapon machinery: psprite machine, hitscan, projectiles, effects, decals, and the drawable weapon and projectile views with their renderers
    │   └── wad/                 WAD reading + IndexedDB storage, game profiles (profile/), on-the-fly converter (convert/)
    └── engine/               Spipu3D — the game-agnostic 3D engine
        ├── libBootstrap.json    Engine bootstrap definition (version + file lists)
        ├── engine3d.js          Viewport, lights, render loop, frustum culling
        ├── collision.js         FPS physics: spatially indexed triangles, box blockers, mover pressure
        ├── spatialGrid.js       Uniform XZ grid over a static triangle set
        ├── entity/              Object3d, Billboard, Instance (keyframes/triggers/cycles), User, UserCommand, World, external forces
        ├── input/               Unified inputs: keyboard, mouse, gamepad, virtual touch gamepad, and the command sampler
        ├── interaction/         Interaction bases (switch modes once/timed/toggle)
        ├── loader/              URL or in-memory loaders (textures, objects, instances, interactions, world)
        ├── sound/               Audio primitives: shared AudioContext with music/effects buses, in-memory PCM samples, tone synthesis, music player over a swappable synth contract
        ├── hud/                 HUD bases (debug overlay, screen flash)
        └── renderer/            webgl / full / flat / fast renderers

In-memory loading

The engine loaders accept either URLs (classic flow, used by the demo pages) or in-memory data (used by the WAD converter):

loader.reset();
loader.beginBatch();                                    // suspends the global finalize check
const texId = loader.textures().loadFromData(null, imageData);          // ImageData
const objId = loader.objects().loadFromData('map', {textures, points, faces});
loader.instances().loadFromData(null, {...instanceData, object: objId});
loader.interactions().loadFromData(new DoomSwitchInteraction(...));
loader.world().loadFromData(definition);                // user, background, lights
loader.setCallback(init);
loader.endBatch();                                      // finalizes everything once, fires init

Webapp bootstrap

Every page is loaded by the generic appBootstrap (global instance). Each library declares its files in a libBootstrap.json definition ({version, files: {assets, css, js}}); pages stack the definitions they need and register their entry point:

<div id="screen"></div>
<script src="/js/webapp/appBootstrap.js"></script>
<script>
function loadApp()
{
    loader.world().load('./assets/world/definition.json');
    loader.setCallback(init);
}

appBootstrap.disablePwaMode();                                       // demos only — doom keeps PWA mode
appBootstrap.addBootstrapDefinition('/js/engine/libBootstrap.json');
appBootstrap.addBootstrapDefinition('./assets/world.json');
appBootstrap.setReadyCallback(loadApp);
</script>

Versions are aggregated (v2.001|v1.018): a change in any stacked definition triggers a full update — in PWA mode the Service Worker clears its cache and re-downloads everything; in classic mode the page reloads with ?v= cache-busted URLs (appBootstrap.buildUrl).

Page pattern

function init() {
    const world = loader.world().get();
    screen = new ScreenManager('screen', { fullscreen: true });  // or { width, height }
                                                                 // or { fullscreen: true, virtualWidth: 1920, virtualHeight: 1080 }
    inputs  = new Inputs().bindScreen(screen);  // one single instance per page, rebound on each level
    sampler = new InputCommandSampler(inputs);  // the devices, turned into one command per turn
    engine = new Engine3d(screen, new Object3dRendererList().getRenderer('webgl'));  // binds itself to the screen
    const hud = new HudDebug(engine)
        .bindUser(world.getUser()).bindInputs(inputs)
        .addDescription('(c)2026 Spipu')
    ;
    screen.bindHud(hud);
    engine.initFromWorld(world);
    requestAnimationFrame(animate);
}

function animate(timestamp) {
    engine.calculateDeltaTime(timestamp);
    const dt = engine.getDeltaTime();
    world.update(dt, new Map([[world.getUser(), sampler.collect(dt).sample()]]));  // one command per user
    engine.displayWorld(world, world.getUser());  // the camera looks through one user
    screen.update(); // updates HUD overlay
    requestAnimationFrame(animate);
}

Versioning

After any file change, increment the version field of the libBootstrap.json of the modified library (engine, doom, or the demo's definition JSON). This drives both the PWA cache refresh and the classic-mode cache busting.

Next steps

The upcoming work is tracked in ./NEXT-STEPS.md.

License

This program is distributed under the MIT License — see the ./LICENSE.md file, except:

  • the website/assets/uzdoom/ directory (impact-decal graphics and finale texts taken from UZDoom), distributed under the GPL v3, with its own LICENSE.md and attribution README.
  • the website/js/lib/libadlmidi/ directory (the vendored libADLMIDI-JS music synthesizer), distributed under the LGPL v3, with its own LICENSE.md and attribution README.
  • the website/js/lib/zxing-wasm/ directory (the vendored QR code library), distributed under the MIT, Apache-2.0 and BSD-3-Clause licences of its three components, all permissive, with its own LICENSE.md.

Languages

JavaScript

97.0%

HTML

2.1%