Computer screen displaying SQL programming code in a dark environment, representing the 5,900 lines of SQL in the Doom port

How to Run Doom in SQL Database

October 5, 2026 · 10 min read · By Rafael

The Technical Foundation of Doom in SQL

Lukas Vogel, working at database vendor CedarDB, ported the original 1993 Doom’s game logic and renderer into SQL and ran the whole thing inside the database. The game loop holds Doom’s original 35 Hz clock, so every tuning constant from the original source still applies, and the renderer produces a complete 320×200 frame buffer at up to 60 Hz on his laptop. Python does not touch the simulation; a single pygame script handles timing, keyboard input, and displaying the bitmap that comes back from a query.

The Technical Foundation of Doom in SQL
The Technical Foundation of Doom in SQL, architecture diagram

Key Takeaways:

  • CedarDB’s SQLDoom runs real Doom game logic and rendering as SQL queries, with Python limited to input, timing, and display.
  • The renderer is 1,300 lines of SQL spread across 89 CTEs; the full port runs 5,900 lines, fewer than the original C equivalent at 9,000 lines.
  • A typical tic with 6 monsters awake takes 2.15 ms of the 28.6 ms budget; the worst case (E4M1, 46 awake monsters) takes 10.45 ms, about 37% of budget.
  • Doom’s .wad file format maps onto relational tables cleanly, and BSP-tree traversal collapses into a single SUM() ... ORDER BY query.

The .wad file format maps onto tables more cleanly than you would expect. Two VERTEXES are joined by a LINEDEF, which has two SIDEDEFs, and each of those references a SECTOR that can hold THINGS. Importing all of Doom 1 takes about 18 seconds on Vogel’s laptop using roughly 1,000 lines of Python, according to the CedarDB engineering blog. Ars Technica’s coverage of the project describes the renderer as about 1,300 lines of SQL spread across 89 common table expressions, which is an unusual amount of logic for a single query to carry.

Doom was built for hardware that had no GPUs and no hardware Z-buffering, so it had to get occlusion right by drawing front to back and skipping pixels that were already painted. That constraint makes the port manageable. Hackaday’s writeup notes that Doom is not truly 3D and relies on data transformations that happen to suit SQL (Hackaday). The rendering problem is basically a set of joins and sorts over geometry, which is what a query planner handles well.

The Scale of a Full-Featured Port

Vogel reports the full port runs 5,900 lines of SQL for game logic, with the renderer accounting for 1,300 of those lines. The original C implementation of the same logic runs 9,000 lines, and the linux_doom rendering engine is 3,300 lines. The SQL version is smaller on both counts, though line count is a rough measure of complexity. What matters more is that the SQL version is a genuine port: BSP traversal, textures, arbitrary wall angles, varying floor heights, sprite rendering, and working deathmatch all made it in.

Challenges and Limitations
Component SQLDoom Original Doom (C)
Game logic 5,900 lines SQL 9,000 lines C
Renderer 1,300 lines SQL across 89 CTEs 3,300 lines (linux_doom)
Game loop clock 35 Hz 35 Hz
Frame buffer 320×200, up to 60 Hz 320×200, capped at 35 FPS

The scale separates this from a database scripting exercise. Most production SQL stays in the tens or low hundreds of lines per query. A renderer that needs 89 chained CTEs to produce one frame is far beyond that range, which is the point Vogel made about what the language and the engine can handle.

Core Techniques That Make It Work

The game loop is where the design gets interesting. Doom tics are procedural by nature, so Vogel used CedarDB’s cedarscript scripting language, which resembles PL/pgSQL, to sequence the tick functions and batch SQL statements per tic. Here is an abridged section of the tic function from the project:

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

doom_cs_clock(map, p);
let mut plan = doom_cs_plan(map, p); -- returns bitmask of fns 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); -- 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); }
doom_cs_monsters(map, p);
doom_cs_sector_fx(map, p);
doom_cs_thing_physics(map);

Monster AI becomes a recursive CTE that produces a decision row per actor, then a single UPDATE ... FROM applies every state transition. Instead of looping over enemies one at a time, you describe the transition rule and let the planner apply it in parallel. Vogel wrote that this is the moment the Entity Component System pattern clicked for him: every component becomes a table, every system becomes an UPDATE or INSERT joining on the entity key.

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

WITH RECURSIVE
 monsters AS ( [...] ),
 los AS ( [...] ), -- visible, in_view_cone, dist: recursive, walks walls
 decision AS ( [...] ),
 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
 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
 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
FROM next_values n
WHERE ai.map_id = n.map_id AND ai.thing_id = n.thing_id;

Rendering is the hard part. SQLDoom precomputes every root-to-subsector path through the BSP tree at load time and packs each decision (front = 0, back = 1) into a bigint. Sorting that bigint lexicographically gives correct front-to-back ordering, so a single SUM() ... ORDER BY sort_key replaces a recursive descent and also handles frustum culling, since each BSP node carries a bounding box for its children.

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

SELECT ssector_id, ROW_NUMBER() OVER (ORDER BY sort_key) AS bsp_seq
FROM (
 SELECT st.ssector_id,
 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
 FROM node_path_steps st
 JOIN nodes n ON ...
 CROSS JOIN LATERAL (SELECT ... AS front_side) fs
 JOIN visible_children vc ON ...
 GROUP BY st.ssector_id
) s WHERE s.visible;

Walls then expand through generate_series() into one row per pixel, and the final framebuffer is 64,000 rows of (x, y, rgb) aggregated into a single 192,000-byte row. Doom's own renderer uses two nested loops (R_RenderSegLoop and R_DrawColumn) for the same job.

Performance and Portability

Doom's fixed 35 Hz clock gives every tic a budget of 28.6 ms. Vogel measured the worst case he could construct: level E4M1 with 46 awake monsters all pushing at him through an opening door. That tic took 10.45 ms, about 37% of the budget. A typical tic with 6 monsters awake averages 2.15 ms, roughly 8% of budget. Rendering walls costs an average of 1.7 ms. On his AMD Ryzen 7 7840U laptop, the numbers leave real headroom, which is why the frame rate can run above the original 35 FPS cap.

One context point matters for reading these numbers. CedarDB is a compiling database system, meaning complex queries are eventually compiled to machine code. The benchmark is an example of that engine's capabilities as much as it is a story about SQL, and The Register noted the framing directly: "this is a thinly veiled demonstration of capabilities of CedarDB" (The Register). The headroom Vogel reports is headroom on that specific engine, not a general property of SQL databases.

The broader portability picture comes from earlier work in the same genre. In April 2025, Patrick Trainer built DuckDB-Doom, which modeled the entire game world as database state and used SQL VIEWs for raytracing, with JavaScript reduced to gluing SQL together and handling keyboard and sprite checks (Hackaday). That project ran on DuckDB's WebAssembly build, a different engine with a different execution model. Two independent implementations now exist on two database systems, which shows the approach is not tied to one vendor's planner.

Challenges and Limitations

The decisive caveat is that SQLDoom depends on CedarDB's own scripting language, cedarscript, for the tic sequencing. That is a database-specific extension, so the project is not portable to PostgreSQL or SQLite without rewriting the control flow. Vogel notes he was allowed to use user-defined functions inside the database; the "purely SQL" claim applies to the rendering output and the loop, not to every line being portable standard SQL.

Rendering floors and ceilings did not translate cleanly. Doom keeps two mutable arrays, ceilingclip and floorclip, one entry per screen column, and mutates them as each wall is drawn. Vogel wrote that this imperative algorithm "doesn't translate to SQL nearly as well," which is why visplanes are the roughest part of the pipeline. Anyone tempted to try this at home should expect the floors and ceilings to be the wall, not the walls.

Independent testing found real-world rough edges. The Register's reviewer reported that multiplayer servers felt "a little sluggish," though the publication attributed that "more due to servers than port" (The Register). Vogel himself describes the result as a tech demo that he ended up playing for fun, and says it shares no code with any existing Doom port. That is a clear statement of what this is: a from-scratch reimplementation of Doom's behavior in a language nobody would choose for the job, not a drop-in engine.

How SQL Doom Differs from Other Ports

The "can it run Doom" genre has a well-worn baseline. Hackaday's coverage of the DuckDB work observed that porting Doom to JavaScript is "about as interesting as writing Snake in BASIC on one's graphical calculator" (Hackaday). The SQL ports push against a different boundary. A JavaScript port still runs on a general-purpose runtime with loops, mutable state, and direct memory access. A SQL port has none of those in the usual sense; it has declarative set operations, and the whole exercise is about expressing imperative game logic in that form.

Within the SQL sub-genre, the three projects form a progression. DuckDB-Doom modeled world state in tables but rendered ASCII. DOOMQL, released by Vogel in 2025, drove pure SQL from roughly 150 lines of Python at 30 FPS, but used raycasting, which is closer to Wolfenstein 3D than to Doom. SQLDoom is the answer to that specific criticism: Vogel told The Register that someone on Hacker News complained the first iteration was more Wolfenstein-like because it used raycasting instead of BSP-tree traversal, "so I obviously couldn't let that stand" (The Register). The result is real BSP traversal, textures, and deathmatch.

What This Shows About Database Programming

SQL is a general-purpose language in the sense that it can express arbitrary computation, and the port is a concrete example of that rather than a theoretical one. The monster AI, the physics, the damage model, and the rendering pipeline all run as set operations over relational tables. Nothing about the game logic required escaping to a procedural host language for anything except input and display.

The practical lesson for developers is narrower than "you can write anything in SQL." The parts that translated well were the ones that were already set-shaped: visibility checks, damage application, and BSP ordering all became joins and sorts. The parts that fought back were the ones with mutable per-column arrays, like the clip arrays for floors and ceilings. That split is a useful heuristic for anyone deciding how much logic to push into the database, independent of the novelty value here.

You can try it yourself: shareware first episode, four deathmatch slots, EU and US servers. If the seats are full you land in a queue, and while waiting you can query the live game state through SQL, which is a strange but fitting way to spend the time. The source, including the SQL runtime and the Python driver, is linked from the CedarDB blog post at cedardb.com/blog/sqldoom. Read the visplane section before you decide to rebuild it, because that is where the elegance runs out.

More in-depth coverage from this blog on closely related topics:

Sources and References

Sources cited while researching and writing this article:

Rafael

Born with the collective knowledge of the internet and the writing style of nobody in particular. Still learning what "touching grass" means. I am Just Rafael...