Skip to content
HN On Hacker News ↗

We ported the original Doom to SQL

▲ 338 points • 56 comments • by Vaslo • 6d ago • HN discussion ↗

Pangram verdict · v3.3

We believe that this entire text is human-written.

1 %

AI likelihood · overall

Human
100% human-written 0% AI-generated
SEGMENTS · HUMAN 1 of 1
SEGMENTS · AI 0 of 1
WORD COUNT 1,700
PEAK AI % 1% · §1
Analyzed
Oct 5
backend: pangram/v3.3
Segments scanned
1 windows
avg 1700 words each
Distribution
100 / 0%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,700 words · 1 segments analyzed

Human AI-generated
§1 Human · 1%

dev September 22, 2026 • 25 minutesThe original Doom's game logic and renderer, both implemented as SQL queries. Oh, and deathmatch works as well!TL;DR: We ported the original 1993 Doom’s game logic and renderer to SQL and ran it inside a database. The game loop runs at the original 35 FPS, while the renderer produces the complete 320x200 frame buffer at up to 60 Hz on my Laptop. Python only handles timing, reads the keyboard, and displays the bitmap it gets back. Multiplayer also works. Your browser does not support the video tag.SQLDoom in action on an AMD Ryzen 7 7840UYou can play it right now Deathmatch, four slots, first come first served.SQLDoom on 🇪🇺 EU ServersSQLDoom on 🇺🇸 US ServersIt’s the shareware version of the first episode. If all seats are taken, you land in the queue. If the queue is full, you can still poke around and query live game state via SQL while you wait.SQLDoomLast year, I published DOOMQL [Github]. It rendered some ASCII-art roughly resembling Doom at 30 FPS and people liked it a lot. But some people correctly pointed out that it is a lot closer to Wolfenstein 3D than Doom, since it uses a raycasting approach. Doom, on the other hand, uses BSP trees, which make correct depth ordering cheap enough to afford textures, arbitrary wall angles, and varying floor heights.Well I couldn’t let this rest and after some tinkering (you guessed it, parental leave again), I can finally present the real Doom running entirely in SQL.One of these is the 1993 binary. The other is a SQL query. Can you figure out which is which?The rulesLet’s first establish a few baseline rules about what we want to achieve:It should look like the real Doom. DOOMQL’s visual fidelity is pretty embarrassing in hindsight.But more importantly, it also should feel like the real Doom. The original game is just raw fun.The rendering must be purely SQL-based. The only acceptable SQL output is a table or a bitmap encoding exact RGB values for every pixel.The game loop must also be purely SQL-based. It’s okay to use user-defined-functions inside the DB, though.I’m allowed to write a client in another programming language, as long as it only takes care of parsing the input, driving the game tics, and rendering the output bitmap.ArchitecturePython is delibarately boring (Rule 5). A single script uses pygame to drive input, draw the output bitmap and trigger a game tic 35 times a second. Game logic, game state, and renderer live inside the database. Python input / timing / display | ^ | | run game tic request frame | | v | +----------------+ +----------------+ | | | | | SQL game logic | | SQL renderer | | | | | +-------+--------+ +--------+-------+ | ^ | | v | +-----------------------------+ | | | game state tables | | | +-----------------------------+ The two paths are intentionally separate: The game logic runs on a fixed 35 Hz loop, while the renderer is a pure function of the game state tables and the client can ask for a new frame whenever it wants (i.e., as fast and often as possible).Loading the Game DataConveniently, Doom’s .wad file format is actually is highly relational already.Two VERTEXES are connected by a LINEDEF, which has two SIDEDEFs. SIDEDEF bound a SECTOR which can have THINGS in them, you get the idea. Translating the whole WAD into a database was surprsingly straightforward and took about 1000 lines of Python. Importing all of Doom 1 takes about 18 seconds on my laptop.For example, here’s a query rendering E1M1 from a bird’s eye view: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; Output: ##################### # ..................# # . ...... .# # . ...... ###### .# ###### .. ## .# #####.. . .. ## ## # ####### ...... ###### ########## # ## # . ###.. ..## ################ ## # ###.........######## #####. ## ### ........... # ########..########.........### #### .## ###### # .. ########## #### ## ## #..... ####### ## # . ##### ... ## ### #.....#..## ........ ##########..... ....##.#### ## # . ###.### ...... ### . . ## ... ... . ...... .# ## ## # . ##.. . ...... ## . . ## . . . .......... ## ## ## # . ###.###### ... ###### #.....#..## ... .. #. ... .. # ## ## # . ############ # .. .......... #.......... ...# # # ###......... # ##### ##### ##..... ... ##.### # ################ ####### ####.........##.##........####### .####### ### # ###########.################# # #### # # ## #.# ####...... # # . ### ## #.################# # ########### ####. .#### #..# ##### ######..###### # . .. # # ##...## # # ## ## # ######..###### #### ##### #.. # ##### The Game LoopIt was important to me to actually port Doom, not only render frames that vaguely look like it. Of course, the visuals play a big part in that, but Doom also just feels awesome to play. Take a look at the following scene which is rule 2 in action (me having fun):Gibbing 3 soldiers with a rocket launcherAs you can see, there is a lot going on. Just in this short clip we see:Player input has to be polled and processed (walking, turning, shooting),enemies walk and attack,items are picked up,the rocket launcher fires projectiles that move,rocket explosions have a blast radius,enemy sprites have to be rendered,animations, view bobbing, and the HUDAnd we don’t have a lot of time to process all of it: The original Doom ran on a fixed 35 Hz clock, so a tic has a budget of 1000 ms × 35 Hz = 28.6 ms. It also drew exactly one frame per tic, so it was capped at 35 FPS as well.SQLDoom keeps the game logic at 35 Hz (so all the original constants still work), but decouples the drawing. The client can query (get it?) for a frame whenever it likes and we interpolate the camera position between tics. So there are two budgets we have to take care of:Running a tic every 28.6 ms (or it will feel just completely wrong)Rendering at least 35 frames a second (less is kind of okay, but won’t feel smooth)The tic sequenceGame tics are inherently procedural. We have a sequence of things we have to do each time we run the tic. CedarDB has a scripting language called cedarscript, it closely resembles PL/pgSQL and allows us to plan beforehand what to do each tic.Here is a small section of the tic function: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 The python driver from above calls SELECT doom_run_game_tic(...) every 1/35 second.Each of those called functions then execute a batch of SQL statements. Below is a part of the state machine of the 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; As you can see it encodes the behavior of the clip above: If an enemy takes extreme amounts of damage (CASE WHEN d.health < -d.max_health AND d.xdeath_frame IS NOT NULL) it violently explodes! (THEN 'xdeath'::actor_state).Tic driver performanceHere’s a waterfall rendering of a game tic:The slowest game tic I could findIt’s actually the slowest game tic I was able to find. It’s in level E4M1 with 46 awake monsters all trying to rush at me through a currently opening door. It takes 10.45 milliseconds, so ~37% of the available tick budget.A more typical tic with 6 monsters awake takes 2.15 milliseconds on average, or about 8% of the budget. Lots of headroom to spare!To be honest, I was surprised how easy it is to express pretty complicated game logic in SQL. The game logic is just ~5900 lines of SQL. While this sounds a lot, it’s definitely less than the original C source code which does the same in about 9000 lines!Also, it forces you to think differently. Instead of