We hebben het originele Doom naar SQL geporteerd
Auteur: Lukas Vogel Datum: 22 september 2026
TL;DR
We hebben de game-logica en renderer van het originele Doom uit 1993 geporteerd naar SQL en deze binnen een database uitgevoerd. De game loop draait op de originele 35 FPS, terwijl de renderer op mijn laptop een volledige framebuffer van 320x200 produceert met een snelheid tot 60 Hz. Python wordt uitsluitend gebruikt voor de timing, het uitlezen van het toetsenbord en het tonen van de bitmap. Multiplayer werkt eveneens.
---
De regels
Om te bepalen wat we wilden bereiken, hebben we een aantal basisregels opgesteld:
- Het moet eruitzien als het echte Doom.
- Het moet voelen als het echte Doom; de originele game is simpelweg puur plezier.
- De rendering moet puur SQL-gebaseerd zijn. De enige acceptabele SQL-output is een tabel of een bitmap die exacte RGB-waarden voor elke pixel codeert.
- De game loop moet puur SQL-gebaseerd zijn. Het gebruik van user-defined functions binnen de database is toegestaan.
- Er mag een client in een andere programmeertaal worden geschreven, zolang deze alleen verantwoordelijk is voor het parsen van de input, het aansturen van de game tics en het renderen van de output-bitmap.
Architectuur
Python is bewust simpel gehouden (Regel 5). Een enkel script gebruikt pygame om de input aan te sturen, de output-bitmap te tekenen en 35 keer per seconde een game tic te triggeren.
De game-logica, de gamestate en de renderer bevinden zich in de database. De twee paden zijn bewust gescheiden: de game-logica draait op een vaste loop van 35 Hz, terwijl de renderer een pure functie is van de gamestate-tabellen. De client kan een nieuw frame aanvragen wanneer hij dat wil (dus zo snel en vaak mogelijk).
Het laden van de game-data
Het .wad-bestandsformaat van Doom is van nature al sterk relationeel. Twee VERTEXES worden verbonden door een LINEDEF, die weer twee SIDEDEFs heeft. SIDEDEFs begrenzen een SECTOR, waarin THINGS kunnen voorkomen.
Het vertalen van de volledige WAD naar een database was verrassend eenvoudig en kostte ongeveer 1000 regels Python. Het importeren van alle data van Doom 1 duurt ongeveer 18 seconden op mijn laptop.
Hier is bijvoorbeeld een query die E1M1 rendert vanuit een vogelperspectief:
WITH wall AS (
SELECT round((v1.x + (v2.x - v1.x) * t / 32.0) / 48) AS col, -- 48 units per column
round((v1.y + (v2.y - v1.y) * t / 32.0) / 96) AS row, -- chars are 2:1
l.left_sd_id < 0 AS solid -- one-sided lines are pass-through
FROM linedefs l, generate_series(0, 32) AS t -- walk each line in 32 steps
JOIN vertexes v1 ON (v1.map_id, v1.id) = (l.map_id, l.v1_id)
JOIN vertexes v2 ON (v2.map_id, v2.id) = (l.map_id, l.v2_id)
WHERE l.map_id = 1
)
SELECT string_agg(CASE WHEN (col, row) IN (SELECT col, row FROM wall WHERE solid) THEN '#'
WHEN (col, row) IN (SELECT col, row FROM wall) THEN '.'
ELSE ' ' END, '' ORDER BY col)
FROM generate_series(-16, 79) AS col, generate_series(-51, -21) AS row
GROUP BY row ORDER BY row DESC;
De game loop
Het was belangrijk om Doom daadwerkelijk te porteren, en niet alleen frames te renderen die er vaag op lijken. In een korte scène waarin drie soldaten met een rocket launcher worden uitgeschakeld, is veel aan de hand:
- Speler-input moet worden gepolled en verwerkt (lopen, draaien, schieten).
- Vijanden lopen en vallen aan.
- Items worden opgepikt.
- De rocket launcher vuurt projectielen af die bewegen.
- Raketexplosies hebben een blast radius.
- Enemy sprites moeten worden gerenderd.
- Animaties, view bobbing en de HUD.
De originele Doom draaide op een vaste klok van 35 Hz, wat betekent dat een tic een budget heeft van $1000\text{ ms} \times 35\text{ Hz} = 28,6\text{ ms}$. SQLDoom houdt de game-logica op 35 Hz, maar ontkoppelt het tekenen. De client kan een frame opvragen wanneer hij wil, waarbij de camerapositie tussen de tics wordt geïnterpoleerd.
De tic-volgorde
Game tics zijn inherent procedureel. CedarDB heeft een scripttaal genaamd cedarscript (verwant aan PL/pgSQL), waarmee we vooraf kunnen plannen wat er elke tic moet gebeuren.
Een klein gedeelte van de tic-functie ziet er als volgt uit:
doom_cs_clock(map, p);
let mut plan = doom_cs_plan(map, p); -- returns a bitmask of functions to trigger
let use_queued = doom_tic_use(map, p, plan);
if (plan & 2) <> 0 OR use_queued { active = doom_cs_activate_specials(map); }
if (plan & 4) <> 0 OR active <> 0 { doom_cs_doors(map, p); }
doom_tic_move(map, p); -- full movement, or just turning
doom_cs_death(map, p); -- process deaths
plan = doom_cs_plan(map, p); -- the world moved; re-plan
plan = doom_tic_secrets(map, p, plan); -- secrets, walkover lines, pickups
plan = doom_tic_weapon(map, p, plan); -- weapon state, hitscan, damage
...
if sound_due { doom_cs_sound(map, p); } -- yes, we also play sounds
doom_cs_monsters(map, p); -- always
doom_cs_sector_fx(map, p); -- always
doom_cs_thing_physics(map); -- always
De Python-driver roept elke 1/35 seconde SELECT doomrungame_tic(...) aan. Elke functie voert vervolgens een batch SQL-statements uit. Hieronder staat een deel van de state machine van de monster AI:
-- Abridged from sql/runtime/functions/26_cs_monsters.sql.
WITH RECURSIVE
monsters AS ( [...] ), -- who is alive, what kind, where
los AS ( [...] ), -- visible, in_view_cone, dist: recursive, walks walls
decision AS ( [...] ), -- one row per actor: its state and what it can see
transitions AS (
SELECT d.*,
CASE
WHEN NOT d.alive AND d.state NOT IN ('die', 'dead', 'xdeath') THEN
CASE WHEN d.health < -d.max_health AND d.xdeath_frame IS NOT NULL
THEN 'xdeath'::actor_state ELSE 'die'::actor_state END -- GORY EXPLOSION!
WHEN d.state = 'stand' THEN
CASE WHEN d.visible AND d.in_view_cone AND d.dist <= sight_range
THEN 'see'::actor_state ELSE 'stand'::actor_state END
WHEN d.state_tics > 1 THEN d.state -- animation still running
WHEN d.state = 'see' THEN
CASE WHEN d.visible AND d.dist <= d.attack_range
AND d.attack_cooldown <= 0
THEN 'missile'::actor_state ELSE 'see'::actor_state END
[...] -- die, xdeath, missile, pain, barrel: 5 more
ELSE d.state
END AS next_state
FROM decision d
)
UPDATE monster_ai ai
SET state = n.next_state, state_tics = n.next_tics, seq_index = n.next_seq,
fired_this_tick = n.advances AND n.lands_on_attack_frame
FROM next_values n
WHERE ai.map_id = n.map_id AND ai.thing_id = n.thing_id;
Prestaties van de tic-driver
De traagste game tic die ik kon vinden, in level E4M1 met 46 actieve monsters, duurde 10,45 milliseconden (ongeveer 37% van het beschikbare budget). Een typische tic met 6 actieve monsters duurt gemiddeld 2,15 milliseconden (ongeveer 8% van het budget).
De game-logica bestaat uit ongeveer 5900 regels SQL. Dit is aanzienlijk minder dan de originele C-broncode, die ongeveer 9000 regels nodig heeft voor dezelfde functionaliteit. Bovendien dwingt SQL je om anders te denken: in plaats van enemies één voor één te doorlopen, schrijf je een simpele UPDATE ... WHERE-conditie en laat je de database bepalen hoe dit het beste (en automatisch parallel) kan worden uitgevoerd.
Dit maakte het Entity Component System (ECS) patroon voor mij duidelijk. In SQL wordt elke component een tabel en elk systeem een update of insert die de relevante tabellen joint met de entity als join-key.
Rendering
Elk frame is in feite een gigantische view die de level-geometrie, de gamestate en de spelerspositie als input neemt en een volledige framebuffer teruggeeft.
De implementatie bestaat uit ongeveer 1300 regels SQL (exclusief commentaren), verspreid over 89 CTE's (Common Table Expressions). Ter vergelijking: de linux_doom broncode gebruikt ongeveer 3300 regels voor de rendering engine.
BSP-traversering
Omdat GPUs in 1993 geen hardware-versnelde Z-buffering hadden, moest Doom de occlusie correct regelen door in de juiste volgorde te tekenen (van voor naar achter). Dit gebeurt via vooraf berekende BSP-trees (Binary Space Partitioning).
Door de BSP-tree recursief te traverseren, krijgen we een volgorde van alle subsectoren. Zodra een regio op het scherm is bedekt door iets dat dichterbij is, kunnen objecten daarachter worden overgeslagen.
SQLDoom optimaliseert dit door alle paden in de BSP-tree één keer bij het laden te berekenen. Voor een bepaalde positie wordt elke stap in zo'n pad gecodeerd als voor (0) of achter (1). Door deze beslissingen in een bigint te pakken en lexicografisch te sorteren (ORDER BY), krijgen we de juiste volgorde:
SELECT ssector_id, ROW_NUMBER() OVER (ORDER BY sort_key) AS bsp_seq
FROM (
SELECT st.ssector_id,
-- back = 1 at bit (40 - depth), front = 0.
SUM(CASE WHEN st.side = fs.front_side THEN 0::bigint
ELSE (1::bigint << (40 - st.depth)) END) AS sort_key,
BOOL_AND(vc.keep) AS visible -- was any parent bbox culled?
FROM node_path_steps st -- materialized view, every root-to-ssector path
JOIN nodes n ON ...
CROSS JOIN LATERAL (SELECT ... AS front_side) fs -- on which side are we?
JOIN visible_children vc ON ...
GROUP BY st.ssector_id
) s WHERE s.visible;
Muren en Visplanes
Doom is technisch gezien een 2.5D-game: het is een plat oppervlak met perfect verticale muren en plafonds die altijd parallel aan de grond lopen.
Muren: We tekenen muren één voor één, van voor naar achter, door rijen en kolommen uit te breiden via generate_series(). Het renderen van de muren kost gemiddeld 1,7 ms.
Visplanes: Dit zijn de vloeren en plafonds. In C wordt dit gedaan via imperatieve loops met ceilingclip en floorclip arrays. SQL heeft geen loops of mutable state, dus gebruiken we sorteren en aggregeren via window functions.
Hier is een verkorte weergave van de query voor visplanes:
panel_clips AS (
-- 1. de band zoals de NABIJERE panels deze hebben achtergelaten
SELECT p.*,
COALESCE(MAX(CASE WHEN part IN ('solid','upper','upper_flush')
THEN y_bot::int + 1 END) OVER w, 0) AS cc_before,
COALESCE(MIN(CASE WHEN part IN ('solid','lower','lower_down')
THEN y_top::int - 1 END) OVER w, screen_h - 1) AS fc_before
FROM panel_seq p
WINDOW w AS (PARTITION BY col_x ORDER BY depth_x, bsp_seq, part, seg_id
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)
),
plane_spans_raw AS (
-- 2. wat de band onbedekt laat is een plafond boven de muur...
SELECT col_x, fsec AS sector_id, f_ceil AS plane_z, 'ceil' AS plane,
cc_before AS y0, -- van waaruit nabijere muren stopten
f_ceil_y::int - 1 AS y1 -- naar beneden tot het plafond van dit panel
FROM panel_clips
WHERE part IN ('solid','upper','upper_open','upper_flush')
AND f_ceil_y::int - 1 >= cc_before -- niets open gelaten: skip
UNION ALL
-- ...en een vloer eronder
SELECT col_x, fsec, f_floor, 'floor',
f_floor_y::int AS y0, -- vanaf de vloer van dit panel
fc_before AS y1 -- naar beneden tot waar nabijere muren stopten
FROM panel_clips
WHERE ...
)
De "lelijke" kant: Depth Resolve
Omdat we geen fixed-point rekenkunde implementeren zoals in het origineel, kunnen verschillende muren, vloeren en luchten elkaar overlappen. Om dit op te lossen gebruiken we een brute-force methode: we genereren alle kandidaten en kiezen de winnaar op basis van diepte.
We pakken de diepte, prioriteit en kleur in een bigint en selecteren de MIN om de winnaar te bepalen. Dit is het duurste onderdeel van het frame: gemiddeld 8,2 milliseconden.
Rendering-prestaties
Op mijn laptop (Ryzen 7 PRO 7840U) haal ik gewoonlijk ongeveer 60 FPS, maar in zeer drukke scènes zakt dit naar 35 FPS. De duurste onderdelen zijn:
- Het renderen van visplanes (emulatie van een iteratief algoritme).
- Depth resolve.
- Per-pixel werk (zoals colormap lookup en het pakken van de framebuffer).
Waar een database wel een goed idee is
Hoewel het renderen van Doom in een database een slecht idee is, zijn er gebieden waar het juist uitstekend werkt.
Alles is data
Het vertalen van item-eigenschappen naar een relationele dataset maakt het zeer eenvoudig om te zien wat de game bevat en om dit aan te passen. De shotgun van de speler is simpelweg een rij in een tabel:
doom=# SELECT name, ammo_type, ammo_per_shot, pellet_count,
doom-# dmg_dice_count, dmg_dice_mult, max_range
doom-# FROM weapon_defs WHERE name = 'shotgun';
Zelfs animaties zijn data. De hele state machine van de shotgun staat in een tabel, wat modding extreem makkelijk maakt. In plaats van bestanden aan te passen en de game te herstarten, kunnen we direct de tabel updaten.
Multiplayer komt bijna gratis
Door een database te gebruiken, krijgen we veel functionaliteiten gratis die game-ontwikkelaars normaal zelf moeten bouwen:
- Authenticatie.
- Concurrency control.
- Toegangscontrole.
- Consistente snapshots van de gamestate.
- Binair wire-protocol.
Het mooiste is de atomiciteit: we kunnen elke game tic starten met BEGIN TRANSACTION en afsluiten met COMMIT. Elke speler krijgt zo een consistent beeld van de wereld, zonder gedeeltelijke updates of physics-bugs.
Toegangscontrole is eveneens elegant. Terwijl SQLDoom ongeveer 110 tabellen heeft, mogen de vier spelers alleen interageren via een paar goed gedefinieerde API-functies. We trekken alle andere rechten in.
Een voorbeeld van de input-functie:
CREATE OR REPLACE FUNCTION api_input(
p_fwd real, p_strafe real, p_run boolean, p_turn real,
p_fire boolean, p_weapon integer, p_use boolean) RETURNS integer
LANGUAGE cedarscript SECURITY DEFINER AS $doom$
INSERT INTO mp_inputs
SELECT mp.map_id, mp.player_thing_id,
LEAST(1.0, GREATEST(-1.0, COALESCE(p_fwd, 0)))::real,
LEAST(1.0, GREATEST(-1.0, COALESCE(p_strafe, 0)))::real,
[...]
FROM mp_players mp WHERE mp.role_name = session_user::text;
return 1;
$doom$;
Bonus: SQL compileren
Je zou denken dat een database in C++ die SQL interpreteert inefficiënt is. CedarDB is echter een compilerende database: elke complexe query wordt via LLVM IR omgezet naar machinecode.
Bij het vergelijken van de gegenereerde machinecode met de originele linux_doom C-code, bleek het verschil verrassend klein. De C-code voor bewegingslogica compileert naar 48 instructies, terwijl SQLDoom er 117 gebruikt. 42 van die extra instructies zijn nodig om het resultaat weer in een tabel op te slaan, iets wat C niet hoeft te doen.
Conclusie
John Carmack was een genie. De BSP-tree aanpak is veel sneller en visueel superieur aan de raycasting-aanpak die ik in mijn eerdere project, DOOMQL, gebruikte. Het bewijst dat de "beste match" voor een taal (raycasting voor SQL) niet altijd het beste resultaat geeft.
Zelf aan de slag
Het project staat op Github: github.com/cedardb/sqldoom.
Je hebt het volgende nodig:
- CedarDB Community Edition.
- Python met
psycopg2enpygame. - Een Doom IWAD-bestand (bijv. de gratis
doom1.wadshareware versie).
Volg de README en je hebt binnen korte tijd je eigen SQLDoom draaien.
Groetjes,