SQLDoom: Running the Original 1993 Doom Engine Entirely Inside a SQL Database

TL;DR

SQLDoom runs the original 1993 Doom’s game logic and renderer completely inside CedarDB using only SQL queries, keeping the game loop at the classic 35 Hz tick rate and delivering a full 320×200 framebuffer at up to 60 FPS; a thin Python client handles input, timing, and bitmap display, and the same engine powers a four‑player deathmatch server.


What SQLDoom Is

SQLDoom is a faithful port of the share‑ware episode 1 of Doom that moves all game logic, state, and rendering into SQL statements executed by the CedarDB database engine. The only external code is a minimal Python script that:

  1. Reads keyboard/mouse input via pygame.
  2. Calls SELECT doom_run_game_tic(...) every 1/35 s to advance the world.
  3. Requests a new frame with a single SQL query and draws the returned bitmap.

Everything else—monster AI, player movement, weapon handling, BSP traversal, wall/visplane rasterisation—lives in tables, stored procedures, and CTE‑heavy queries.


Architecture Overview

The system is split into two independent pipelines:

  • Game‑logic pipeline – runs at a fixed 35 Hz, updates the relational game‑state tables, and returns nothing but the updated state.
  • Renderer pipeline – a pure‑function SQL query that reads the current state tables and emits a 64 000‑row result containing (x, y, rgb) for every pixel.
Python (pygame) ↔️ SQL logic tables ↔️ SQL renderer → bitmap → display

The separation allows the client to request frames as fast as it likes (the author reports up to 60 FPS) while the logic stays locked to the original timing budget of 28.6 ms per tic.


Loading Doom Data into a Relational Model

The Doom WAD format already maps cleanly to relational tables:

  • VERTEXES → vertexes
  • LINEDEFS → linedefs
  • SIDEDEFS → sidedefs
  • SECTORS → sectors
  • THINGS → things

A 1000‑line Python importer parses the WAD and populates ~110 tables in about 18 seconds on a laptop. The relational schema makes the level geometry directly queryable, as demonstrated by a bird‑eye ASCII rendering query included in the blog post.


Game‑Tick Implementation

A single stored procedure, doom_run_game_tic, encapsulates the entire tick. Inside, CedarScript (a PL/pgSQL‑like language) builds a bitmask plan that decides which subsystems need to run this tick. The procedure then calls a cascade of functions such as:

  • doom_cs_move – player and monster movement
  • doom_cs_doors – door opening/closing
  • doom_cs_weapon – weapon state, hitscan, damage
  • doom_cs_monsters – monster AI state machine
  • doom_cs_sector_fx – sector effects (floors, ceilings)

The monster AI example shows a recursive CTE that computes line‑of‑sight, distance, and decides the next state with a CASE expression, finally updating monster_ai in a single UPDATE.

Performance: The worst‑case tick (E4M1 with 46 awake monsters) takes 10.45 ms (≈37 % of the 28.6 ms budget). A typical tick with six monsters averages 2.15 ms (≈8 %). The entire logic consists of ~5 900 lines of SQL, fewer than the ~9 000 lines of the original C source.


Rendering Pipeline in SQL

A single SQL query with 89 CTEs produces the full framebuffer. The pipeline mirrors Doom’s classic steps:

  1. BSP traversal – pre‑computed node_path_steps materialised view encodes each root‑to‑subsector path as a 40‑bit sort_key. Ordering by this key yields the front‑to‑back subsector sequence without recursion.
  2. Wall rasterisation – generate_series expands each wall segment into screen columns and then into per‑pixel fragments.
  3. Visplane (floor/ceiling) filling – window functions compute the remaining unclipped band for each column, then emit ceiling/floor spans.
  4. Sprite rendering – similar fragment generation for billboards.
  5. Depth resolve – all candidate fragments are packed into a 64‑bit winner_key where the most‑significant bits encode depth, surface priority, and a stable tie‑breaker. MIN(winner_key) per pixel selects the visible fragment.

Timing breakdown:

  • Wall rasterisation – ~1.7 ms
  • Visplane filling – ~3 ms
  • Depth resolve & per‑pixel work – ~8.2 ms (the biggest cost)
  • Total average frame – ~16 ms, yielding ~60 FPS on a Ryzen 7 7840U; busy scenes drop to the original 35 FPS.

Multiplayer via the Database

Because the entire world lives in a transactional database, multiplayer is essentially free:

  • A single “referee” process drives the 35 Hz clock for all players.
  • Player input is submitted through a SECURITY DEFINER function api_input, which clamps values and inserts a row into mp_inputs.
  • Each tick runs inside a BEGIN … COMMIT block, guaranteeing that every client sees either the pre‑tic or post‑tic snapshot—no partial updates.
  • Role‑based access control limits players to the API functions, preventing arbitrary table manipulation.

The public servers host a four‑player deathmatch with stable 35 FPS on a 3‑core per client setup; a 16‑core machine can comfortably host additional matches.


Why a Database Can Be a Good Fit

  • Data‑centric design – weapons, monsters, and animation frames are rows in tables, making inspection and live tweaking trivial.
  • Atomicity & consistency – each tick is a transaction, eliminating classic desynchronisation bugs.
  • Access control – fine‑grained permissions let you expose only the intended API to players.
  • ECS mapping – entities become rows, components become tables, and systems become set‑based UPDATE/INSERT statements, leveraging the DB’s parallel execution engine.

Compilation Overhead

CedarDB compiles complex queries to LLVM IR before execution. A side‑by‑side assembly comparison shows the SQL‑generated code uses 117 instructions versus 48 for the original C, with the extra instructions largely attributable to writing results back into tables. The gap is modest given the multiple abstraction layers.


Community Reaction (Hacker News Highlights)

"I was surprised how easy it is to express pretty complicated game logic in SQL. The game logic is just ~5900 lines of SQL." – bob1029

"Less lines of code than vanilla C while abusing query planning as a state machine is peak engineering malpractice. I love it." – soltanov

"The game state being a SQL table just triggered a memory of a year of optimisation for me… atomicity guarantees a consistent state… doing that read/write loop for an action game? Pure folly, but it’s pretty funny to me." – noduerme

These comments underline the surprise at SQL’s expressive power, the elegance of using the query planner as a state machine, and the trade‑offs regarding scalability for real‑time action games.


Getting Started

  1. Install CedarDB Community Edition.
  2. Install Python dependencies: psycopg2 and pygame.
  3. Obtain the share‑ware doom1.wad (e.g., apt install doom-wad-shareware).
  4. Clone the repository https://github.com/cedardb/sqldoom and follow the README to load the WAD and start the Python driver.

Alternatively, join a public match on the EU or US servers linked in the original blog post.


Takeaway

SQLDoom demonstrates that a relational database can host a full‑featured, real‑time 3D engine: the game logic fits naturally into set‑based updates, the BSP‑driven renderer can be expressed with CTEs and window functions, and transactional guarantees make multiplayer trivial. While a database is not a practical graphics engine for production games, the project is a compelling proof‑of‑concept that pushes the boundaries of what SQL can express.

Sources

Related

  • Project
  • Project
  • Project
  • Dispatch
  • Project