{"article":{"slug":"we-ported-the-original-doom-to-sql","title":"We ported the original Doom to SQL","subtitle":null,"summary":"CedarDB engineers describe porting the original Doom so the game’s world and logic run as SQL queries—showing what a modern analytical database can do with a playful, technical demo.","content_type":"blog_post","language":"en","canonical_url":"https://cedardb.com/blog/sqldoom/","author":{"name":"CedarDB","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"CedarDB","url":null,"listing_slug":null,"listing":null},"topics":[{"name":"databases","slug":"databases","url":"https://listedarticles.com/topics/databases"},{"name":"programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"open-source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":5155,"reading_minutes":22,"published_at":"2026-10-03T12:00:00.000Z","added_at":"2026-10-03T17:11:10.865Z","updated_at":"2026-10-03T17:11:10.865Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/we-ported-the-original-doom-to-sql","markdown_url":"https://listedarticles.com/articles/we-ported-the-original-doom-to-sql.md","example":false,"citation":"CedarDB, CedarDB. \"We ported the original Doom to SQL.\" 3 Oct 2026. https://cedardb.com/blog/sqldoom/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://cedardb.com/blog/sqldoom/"},"body_markdown":"**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.\n\nYour browser does not support the video tag.\n\nSQLDoom in action on an AMD Ryzen 7 7840U\n\n**You can play it right now** Deathmatch, four slots, first come first served.\n\n[SQLDoom on 🇪🇺 EU Servers](https://demo.cedardb.cloud/projects/38c0ee59-327d-495e-ad40-735521bba941/doom)\n\n[SQLDoom on 🇺🇸 US Servers](https://demo.cedardb.cloud/projects/f23db33c-f37e-4a28-b73a-1c8151661179/doom)\n\nIt’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.\n\n# SQLDoom\n\nLast year, I published [DOOMQL](/blog/doomql) [[Github]](https://github.com/cedardb/doomql). 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](https://en.wikipedia.org/wiki/Binary_space_partitioning), which make correct depth ordering cheap enough to afford textures, arbitrary wall angles, and varying floor heights.\n\nWell 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.\n\nOne of these is the 1993 binary. The other is a SQL query. Can you figure out which is which?\n\n## The rules\n\nLet’s first establish a few baseline rules about what we want to achieve:\n\n  1. It should look like the real Doom. DOOMQL’s visual fidelity is pretty embarrassing in hindsight.\n  2. But more importantly, it also should _feel_ like the real Doom. The original game is just raw _fun_.\n  3. 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.\n  4. The game loop must also be purely SQL-based. It’s okay to use user-defined-functions inside the DB, though.\n  5. 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.\n\n## Architecture\n\nPython 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.\n    \n    \n                             Python\n                     input / timing / display\n                        |              ^\n                        |              |\n              run game tic        request frame\n                        |              |\n                        v              |\n              +----------------+  +----------------+\n              |                |  |                |\n              | SQL game logic |  |  SQL renderer  |\n              |                |  |                |\n              +-------+--------+  +--------+-------+\n                      |                    ^\n                      |                    |\n                      v                    |\n                 +-----------------------------+\n                 |                             |\n                 |       game state tables     |\n                 |                             |\n                 +-----------------------------+\n    \n\nThe 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).\n\n## Loading the Game Data\n\nConveniently, Doom’s [.wad file format](https://doom.fandom.com/wiki/WAD) is actually is highly relational already.\n\nTwo `VERTEXES` are connected by a `LINEDEF`, which has two `SIDEDEF`s. `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.\n\nFor example, here’s a query rendering E1M1 from a bird’s eye view:\n    \n    \n    WITH wall AS (\n      SELECT round((v1.x + (v2.x - v1.x) * t / 32.0) / 48) AS col, -- 48 units per column\n             round((v1.y + (v2.y - v1.y) * t / 32.0) / 96) AS row, -- chars are 2:1\n             l.left_sd_id < 0 AS solid -- one-sided lines are pass-through\n      FROM linedefs l, generate_series(0, 32) AS t        -- walk each line in 32 steps\n      JOIN vertexes v1 ON (v1.map_id, v1.id) = (l.map_id, l.v1_id)\n      JOIN vertexes v2 ON (v2.map_id, v2.id) = (l.map_id, l.v2_id)\n      WHERE l.map_id = 1\n    )\n    SELECT string_agg(CASE WHEN (col, row) IN (SELECT col, row FROM wall WHERE solid) THEN '#'\n                           WHEN (col, row) IN (SELECT col, row FROM wall)             THEN '.'\n                           ELSE ' ' END, '' ORDER BY col)\n    FROM generate_series(-16, 79) AS col, generate_series(-51, -21) AS row\n    GROUP BY row ORDER BY row DESC;\n    \n\nOutput:\n    \n    \n                                                     #####################\n                                                     # ..................#\n                                                     # . ......         .#\n                                                     # . ...... ######  .#\n                                                  ######         .. ##  .#\n                                              #####..  .         .. ##  ##\n                                              #  ####### ...... ######  ##########\n                                              # ##   # .                ###..  ..##\n      ################                       ## #    ###.........######## #####.  ##\n    ###  ........... #                ########..########.........###         #### .##     ######\n    #  ..           ##########     ####         ##                 ##        #..... #######    ##\n    #  .          #####  ... ##  ###   #.....#..##    ........      ##########..... ....##.#### ##\n    #  .     ###.###  ......  ###  .   .        ##  ...      ...             . ......     .#  ##  ##\n    #  .     ##..  .  ......  ##   .   .        ##  .           .            . ..........  ##  ## ##\n    #  .     ###.######  ...  ######   #.....#..##  ...        ..            #.    ... ..   # ## ##\n    #  .           ############    #            ..    ..........             #.......... ...# #  #\n    ###.........     #             #####     #####                           ##..... ... ##.###  #\n      ################                 #######   ####.........##.##........####### .#######  ### #\n                                                     ###########.#################  #  ####  # # ##\n                                                               #.#      ####......  #  # .   ### ##\n                                                               #.#################  #  ###########\n                                                               ####. .####       #..#\n                                                                  #####     ######..######\n                                                                            #   .    ..  #\n                                                                            #   ##...##  #\n                                                                            #   ##   ##  #\n                                                                            ######..######\n                                                                                 ####\n                                                                                 #####\n                                                                                 #.. #\n                                                                                 #####\n    \n\n## The Game Loop\n\nIt 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):\n\nGibbing 3 soldiers with a rocket launcher\n\nAs you can see, there is a lot going on. Just in this short clip we see:\n\n  * Player input has to be polled and processed (walking, turning, shooting),\n  * enemies walk and attack,\n  * items are picked up,\n  * the rocket launcher fires projectiles that move,\n  * rocket explosions have a blast radius,\n  * enemy sprites have to be rendered,\n  * animations, view bobbing, and the HUD\n\nAnd 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.\n\nSQLDoom 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:\n\n  * Running a tic every 28.6 ms (or it will feel just completely wrong)\n  * Rendering at least 35 frames a second (less is kind of okay, but won’t feel smooth)\n\n### The tic sequence\n\nGame 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.\n\nHere is a small section of the tic function:\n    \n    \n    doom_cs_clock(map, p);\n    let mut plan = doom_cs_plan(map, p);    -- returns a bitmask of functions to trigger\n    \n    let use_queued = doom_tic_use(map, p, plan);\n    if (plan & 2) <> 0 OR use_queued { active = doom_cs_activate_specials(map); }\n    if (plan & 4) <> 0 OR active <> 0 { doom_cs_doors(map, p); }\n    \n    doom_tic_move(map, p);                  -- full movement, or just turning\n    doom_cs_death(map, p);                  -- process deaths\n    \n    plan = doom_cs_plan(map, p);            -- the world moved; re-plan\n    plan = doom_tic_secrets(map, p, plan);  -- secrets, walkover lines, pickups\n    plan = doom_tic_weapon(map, p, plan);   -- weapon state, hitscan, damage\n    ...\n    if sound_due { doom_cs_sound(map, p); } -- yes, we also play sounds\n    doom_cs_monsters(map, p);               -- always\n    doom_cs_sector_fx(map, p);              -- always\n    doom_cs_thing_physics(map);             -- always\n    \n\nThe python driver from above calls `SELECT doom_run_game_tic(...)` every `1/35` second.\n\nEach of those called functions then execute a batch of SQL statements. Below is a part of the state machine of the monster AI.\n    \n    \n    -- Abridged from sql/runtime/functions/26_cs_monsters.sql.\n    WITH RECURSIVE\n      monsters AS ( [...] ),   -- who is alive, what kind, where\n      los      AS ( [...] ),   -- visible, in_view_cone, dist: recursive, walks walls\n      decision AS ( [...] ),   -- one row per actor: its state and what it can see\n      transitions AS (\n        SELECT d.*,\n          CASE\n            WHEN NOT d.alive AND d.state NOT IN ('die', 'dead', 'xdeath') THEN\n              CASE WHEN d.health < -d.max_health AND d.xdeath_frame IS NOT NULL\n                   THEN 'xdeath'::actor_state ELSE 'die'::actor_state END -- GORY EXPLOSION!\n            WHEN d.state = 'stand' THEN\n              CASE WHEN d.visible AND d.in_view_cone AND d.dist <= sight_range\n                   THEN 'see'::actor_state ELSE 'stand'::actor_state END\n            WHEN d.state_tics > 1 THEN d.state          -- animation still running\n            WHEN d.state = 'see' THEN\n              CASE WHEN d.visible AND d.dist <= d.attack_range\n                        AND d.attack_cooldown <= 0\n                   THEN 'missile'::actor_state ELSE 'see'::actor_state END\n            [...]                -- die, xdeath, missile, pain, barrel: 5 more\n            ELSE d.state\n          END AS next_state\n        FROM decision d\n      )\n    UPDATE monster_ai ai\n    SET state = n.next_state, state_tics = n.next_tics, seq_index = n.next_seq,\n        fired_this_tick = n.advances AND n.lands_on_attack_frame\n    FROM next_values n\n    WHERE ai.map_id = n.map_id AND ai.thing_id = n.thing_id;\n    \n\nAs 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`).\n\n### Tic driver performance\n\nHere’s a waterfall rendering of a game tic:\n\nThe slowest game tic I could find\n\nIt’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.\n\nA more typical tic with 6 monsters awake takes 2.15 milliseconds on average, or about 8% of the budget. Lots of headroom to spare!\n\nTo 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!\n\nAlso, it forces you to think differently. Instead of iterating over, e.g., enemies one-by-one you just write a simple `UPDATE ... WHERE condition` and let the database figure out how to best apply that - in parallel, automatically!\n\nThat also finally made the [Entity Component System (ECS)](https://en.wikipedia.org/wiki/Entity_component_system) pattern click for me. Here, each `entity` (player, monster, thing, …) has multiple `components` (position, sprite, stats, …) and a `system` (monster ai, move player, damage calculation) decides on how entities with a given set of properties interact with each other. ECS is a lot about data locality and how to iterate over entities that have a given set of components. Well, in SQL we are very used to data intensive processing! Every component becomes a table, and every system becomes an `update` or `insert` that just joins the tables it’s interested in with the entity as join key!\n\n## Rendering\n\nEvery frame is just a giant view that reads the level geometry and game state plus the player position as input and returns a complete framebuffer. Here’s a sketch of the whole rendering pipeline:\n    \n    \n    WITH RECURSIVE\n      render_context AS (SELECT $1 AS map_id, $2 AS player_thing_id, $3 AS difficulty),\n      pos            AS (SELECT $4 AS x, $5 AS y, $6 AS z, $7 AS angle),\n      visible_children AS ( ... ),    -- walk the BSP, culling invisible segments\n      clipped, projected, on_screen,  -- project segments to screen space\n      wall_parts, columns, fragments, -- one row per wall pixel\n      panel_clips, plane_spans, ...,  -- ceiling/floorclip as window functions, visplanes\n      thing_pixels, sprite_fragments, -- sprites\n      fragment_union, resolved,       -- every candidate pixel, resolve for the nearest\n      view_colored, ui_colored,       -- COLORMAP, status bar\n      framebuffer AS ( ... )          -- 64,000 rows of (x, y, rgb)\n    SELECT string_agg(rgb, ''::bytea ORDER BY y, x) AS frame_rgb\n    FROM framebuffer;                  -- 192,000 bytes, one row\n    \n\nThe implementation is ~1300 lines of SQL (excluding comments) spread across 89 CTEs, so pretty complicated for a SQL query!\n\nAll 89 CTEs of a single rendered frame\n\nBut despite looking like complete insanity, this pipeline is actually pretty close to what Doom does. SQL even has one advantage: The [`linux_doom` source](https://github.com/id-Software/Doom/tree/master/linuxdoom-1.10) uses about 3300 lines (excluding comments) for its rendering engine. _About 2.5x more lines than SQLDoom_. Whether it was a good idea in the first place is a different question, and we’ll talk about that later.\n\nLet’s first look at the most interesting parts of the rendering pipeline:\n\nFrame visualization by render stage\n\nThe left half shows bsp-based culling, the right half visualizes wall rendering and visplanes.\n\n### BSP traversal\n\nSince nobody in 1993 had GPUs with hardware-accelerated [Z-buffering](https://en.wikipedia.org/wiki/Z-buffering), Doom had to get occlusion right by drawing in the correct order. The way Doom does it is pretty ingenious: It paints front to back and keeps track of which pixels it already painted (i.e., if I have already drawn a wall pixel, I don’t have to draw the monster behind it). But that’s easier said than done: We need an efficient way to order _everything_ in the level by depth.\n\nDoom gets this ordering by using precomputed [BSP Trees](https://en.wikipedia.org/wiki/Binary_space_partitioning) baked into the `doom.wad` file. Every node of the tree is a line splitting the map in two. The map’s sectors thus get chopped up into a lot of subsectors which are on either side of those lines, and are then inserted into the tree so that we get the following properties:\n\n  1. each subsector is a leaf and\n  2. each subsector is convex (i.e., you can see any wall from anywhere inside it)\n  3. at every tree node, the entire subtree that is on the camera’s side is guaranteed to be _in front of_ the subtree on the other side.\n\nBy recursively traversing the BSP tree, we thus get a _front-to-back_ order of all subsectors. This gives us the rendering order directly: Once a screen region has been covered by something nearer, objects behind it can be skipped.\n\nHere’s how this looks like in motion (you might have to view it in full screen):  Your browser does not support the video tag.\n\nVisualisation of the BSP walk\n\nOn the left, subsectors are ordered front to back, while BSP branches out of view are eagerly culled. In the middle you can see the order that SQLDoom assigns each region. On the right, you see the resulting frame with walls colored according to the subsector they’re in.\n\nThe middle panel shows an optimization SQLDoom makes: For better performance we pre-compute all paths in the BSP tree once at load time. For a given position, every step along such a path is either taking the front (encoded as `0`), or the back (encoded as `1`). If we pack these decision into a bigint, and sort that lexicographically (`order by`), we get the right front to back ordering.\n    \n    \n    SELECT ssector_id, ROW_NUMBER() OVER (ORDER BY sort_key) AS bsp_seq\n    FROM (\n      SELECT st.ssector_id,\n             -- back = 1 at bit (40 - depth), front = 0.\n             SUM(CASE WHEN st.side = fs.front_side THEN 0::bigint\n                      ELSE (1::bigint << (40 - st.depth)) END) AS sort_key,\n             BOOL_AND(vc.keep) AS visible   -- was any parent bbox culled?\n      FROM node_path_steps st -- materialized view, every root-to-ssector path\n      JOIN nodes n ON ...\n      CROSS JOIN LATERAL (SELECT ... AS front_side) fs -- on which side are we?\n      JOIN visible_children vc ON ...\n      GROUP BY st.ssector_id\n    ) s WHERE s.visible;\n    \n\nOne `sum() ... order by` replaces the whole recursive descent! 40 bits should also be able to handle any map we throw at it: The deepest BSP-Tree is that of E4M8 and has just 32 levels. As long as your maps aren’t larger than 256 times the biggest vanilla map, you’re all sorted!\n\nIf you look carefully, you can see that our bsp traversal also handles culling: Conveniently, every node in the `.wad` also defines a bounding box of all of its children. If we can prove that our [view frustum](https://en.wikipedia.org/wiki/Viewing_frustum) is entirely outside of that bounding box, we don’t have to consider that subtree for rendering - that is what `visible_children.keep` signifies. `bool_and(vc.keep)` thus drops all subsector where any ancestor doesn’t qualify.\n\nEverything afterwards in the pipeline is just joined against `bsp_seq` so only visible subsectors are considered and in the right order.\n\n### Walls and Visplanes\n\nDoom is kind of cheating, it looks 3D, but in reality it’s a 2.5D game. It’s essentially just a flat surface with perfectly vertical walls and ceilings always being parallel to the ground. This makes rendering far easier than in a _real_ 3D engine:\n\n  1. Paint all walls (front to back, as discussed)\n  2. Everything that isn’t painted yet, is either a floor or a ceiling. Paint that.\n  3. Sprites (monsters, barrels, pickups) are flat images that always face you (think cardboard cutouts), so no complicated transformations here (except for when they overlap a wall, but we’ll get to that).\n\n#### Walls\n\nA wall occupies a set of contiguous screen columns, and within each column it is a contiguous span of pixels. So we can just paint walls one-by-one, front-to-back by expanding rows and columns via `generate_series()`:\n    \n    \n    columns AS ( -- emit a row per screen column the wall w covers\n      SELECT w.*, x AS col_x, ...\n      FROM wall_parts_tex w\n      CROSS JOIN LATERAL generate_series(\n        GREATEST(0, FLOOR(w.screen_x1)::int),\n        LEAST(screen_w - 1, CEIL(w.screen_x2)::int)) AS x\n    ),\n    fragments AS ( -- one row per pixel the wall covers in this column\n      SELECT c.col_x AS x, y, c.depth_x AS depth, c.u_i, c.v_i\n      FROM clamped_spans c\n      CROSS JOIN LATERAL generate_series(c.y_start, c.y_end) AS y\n    )\n    \n\nDoom uses two loops instead: [`R_RenderSegLoop`](https://github.com/id-Software/Doom/blob/a77dfb96cb91780ca334d0d4cfd86957558007e0/linuxdoom-1.10/r_segs.c#L206) to get the screen columns and [`R_DrawColumn`](https://github.com/id-Software/Doom/blob/a77dfb96cb91780ca334d0d4cfd86957558007e0/linuxdoom-1.10/r_draw.c#L105) to draw the pixels.\n\nRendering the walls cost us on average 1.7 ms.\n\n#### Visplanes\n\nNow that we have the walls out of the way, let’s talk about the fun part: The floors and ceilings, what Doom calls _visplanes_.\n\nUnfortunately, Doom’s rendering algorithm doesn’t translate to SQL nearly as well since it’s highly imperative: Doom keeps two arrays, `ceilingclip` and `floorclip` which have one entry per screen column. They mark the band in each column that is still open (i.e., has to become floor or ceiling and hasn’t been painted yet) Whenever a new wall is painted, they are _mutated_ until every pixel is filled. Not only does Doom mutate them, but it’s also very important to mutate them in _the right order_. It’s ingenious! In the end it’s just a flood fill algorithm, but everything looks 3D basically for free (in C, that is).\n\nSQLDoom has to approach this problem differently, as we don’t have the concepts of loops or mutable state in SQL. So instead of looping, we turn to sorting and aggregating over those sorted runs - a poor man’s loop!\n\nThe things we iterate over here are called _panels_ : One part of a wall appearing in one column of the screen. Some panels draw something: a solid wall (`solid`), the wall above a door (`upper`), or the wall part below a window or a parapet (`lower`), some panels are just there to influence how other panels are rendered: If you step out of a door below a balcony, there’s something above you and that has to end _somewhere_.\n\nSo for each screen column (`col_x`) we have an ordered list of panels from near to far. The clip state before a panel is thus defined entirely by the row preceding it. Do I smell window functions?\n\nSince this is pretty hard to explain in text, let’s watch a video instead!  Your browser does not support the video tag.\n\nDetermining the position of visplanes with window functions\n\nHere’s the (abbreviated) SQL query:\n    \n    \n    panel_clips AS (\n      -- 1. the band as the NEARER panels left it\n      SELECT p.*,\n        COALESCE(MAX(CASE WHEN part IN ('solid','upper','upper_flush')\n                          THEN y_bot::int + 1 END) OVER w, 0)            AS cc_before,\n        COALESCE(MIN(CASE WHEN part IN ('solid','lower','lower_down')\n                          THEN y_top::int - 1 END) OVER w, screen_h - 1) AS fc_before\n      FROM panel_seq p\n      WINDOW w AS (PARTITION BY col_x ORDER BY depth_x, bsp_seq, part, seg_id\n                   ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)\n    ),\n    plane_spans_raw AS (\n      -- 2. whatever the band leaves uncovered is a ceiling above the wall...\n      SELECT col_x, fsec AS sector_id, f_ceil AS plane_z, 'ceil' AS plane,\n             cc_before         AS y0,   -- from where nearer walls stopped\n             f_ceil_y::int - 1 AS y1    -- down to this panel's own ceiling\n      FROM panel_clips\n      WHERE part IN ('solid','upper','upper_open','upper_flush')\n        AND f_ceil_y::int - 1 >= cc_before          -- nothing left open: skip\n      UNION ALL\n      -- ...and a floor below it\n      SELECT col_x, fsec, f_floor, 'floor',\n             f_floor_y::int AS y0,      -- from this panel's own floor\n             fc_before      AS y1       -- down to where nearer walls stopped\n      FROM panel_clips\n      WHERE ...\n    )\n    \n\nWe first calculate for every panel in the scene that potentially renders some pixels how much of the column is still unassigned. And the only pixels that already _could_ be assigned are from all the panels closer (that’s the `ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING` term in (1)). _Then_ we draw some pixels from the end of the previous panel until the beginning of the next panel (2). We do this both for ceilings and floors.\n\nA pretty hacky way to disguise an imperative algorithm as set-based, right? Good thing we have window functions…\n\nRendering floors, ceilings and the sky typically costs about `3 ms`.\n\n#### The ugly part\n\nUnfortunately, I had to lie to you: Walls, visplanes and sprite resolution don’t _draw_ anything yet. They just emit candidates of the form `(x, y, depth, colour)` with potentially many pixels at the same position, but at different depths: Since we don’t implement Doom’s fixed-point arithmetic, we could have different walls, floors and skies overlapping. Also, we have to render sprites, which in turn could be partially occluded by walls. Doom does a very tightly choreographed dance to make sure this can never happen, so that they don’t have to do z-buffering. I tried and failed to reproduce that choreography in SQL, so I gave up and used the brute force method instead: Just generate everything and then pick winners.\n    \n    \n    ((LEAST(depth, 131071.0) * 4096)::bigint << 34) -- depth, clamped to 17.12 fixed-point\n    | ((2 - surface_priority) << 32)                -- wall > sprite > plane\n    | (LEAST(source_priority, 3) << 30)\n    | ((stable_id + 32768) << 14)                   -- stable tiebreak\n    | (light_index << 8) | palette_index            -- the payload\n    AS winner_key\n    ...\n    SELECT pix, MIN(winner_key) FROM ranked_fragments GROUP BY pix\n    \n\nIt’s the same trick as with the BSP tree where we just pack everything into a bigint, and then select the min: The most significant bits are depth, so we can just choose the min to find the winner. And since the payload (i.e., the color of the pixel) is also part of the key, we don’t even have to join again! Seems a bit hacky, but since this is per-pixel work (and a single Doom frame has `320*200=64000` pixels), we have to be careful to not do too much work.\n\nEven with this optimization, it’s still the most expensive part of the frame: `8.2` milliseconds on average, more than a third of the entire frame! And that’s exactly why John Carmack avoided that. But we’re lucky to now have machines that can run this even in SQL and still hit the 35 FPS target. [The future is now, old man!](https://www.youtube.com/watch?v=ta41xU-tkFA).\n\n### Rendering Performance\n\nHere’s a waterfall view of the pipeline compared against Doom’s 35 FPS frame target. \n\nThe rendering pipeline on an AMD Ryzen 7 7840U\n\nOn my Laptop (Ryzen 7 PRO 7840U) I typically get about 60 FPS, but it drops down to 35 FPS on very busy scenes.\n\nThe most expensive parts of the pipeline are (unsurprisingly):\n\n  * rendering visplanes (where we have to emulate an iterative algorithm),\n  * depth resolve (which the original Doom successfully avoids in the first place),\n  * and everything that has to happen per pixel (e.g., colormap lookup, packing the framebuffer)\n\n## Where using a database is _actually_ a good idea\n\nRendering Doom in a database is obviously a bad idea. But there _are_ a few areas where it’s actually a good fit and I’m going to defend them to my death!\n\n### Everything is data\n\nI previously didn’t expect how much I’d enjoy translating properties of items into a relational data set. For one, it makes it really easy to see what your game actually contains, but most importantly it’s also really easy to change.\n\nThe player’s shotgun is just a row:\n    \n    \n    doom=# SELECT name, ammo_type, ammo_per_shot, pellet_count,\n    doom-#        dmg_dice_count, dmg_dice_mult, max_range\n    doom-#   FROM weapon_defs WHERE name = 'shotgun';\n      name   | ammo_type | ammo_per_shot | pellet_count | dmg_dice_count | dmg_dice_mult | max_range\n    ---------+-----------+---------------+--------------+----------------+---------------+-----------\n     shotgun | shells    |             1 |            7 |              3 |             5 |      2048\n    (1 row)\n    \n\nSeven pellets, each doing `3d5` damage.\n\nEven the animation is data! Here’s the entire state machine of the shotgun:\n    \n    \n    doom=# SELECT state, seq_index AS seq, frame, tics,\n    doom-#        is_attack_frame AS shoots, refire_check AS refire\n    doom-#   FROM weapon_frames WHERE weapon_id = 3 ORDER BY state, seq_index;\n     state | seq | frame | tics | shoots | refire\n    -------+-----+-------+------+--------+--------\n     ready |   0 | A     |    1 | f      | f\n     fire  |   0 | A     |    3 | f      | f\n     fire  |   1 | A     |    7 | t      | f\n     fire  |   2 | B     |    5 | f      | f\n     fire  |   3 | C     |    5 | f      | f\n     fire  |   4 | D     |    4 | f      | f\n     fire  |   5 | C     |    5 | f      | f\n     fire  |   6 | B     |    5 | f      | f\n     fire  |   7 | A     |    3 | f      | t\n     fire  |   8 | A     |    7 | f      | f\n     flash |   0 | A     |    4 | f      | f\n     flash |   1 | B     |    3 | f      | f\n    (12 rows)\n    \n\nProperties of things just being stored in a table also makes it _really_ easy to mod _everything_. Take a look at the following clip where I’m frustrated I’m not doing enough damage, and just mod the shotgun to shoot 500 pellets at once at a higher spread!\n\nChea...Modding the shotgun\n\nOf course, we _could_ have also just stored everything in files in e.g. JSON but that means\n\n  1. constraints aren’t verified at modification time and\n  2. We’d have to reload for changes to take effect.\n\n### Multiplayer almost comes for free\n\nWell, now we went through all of this hassle to port over Doom to SQL and haven’t even taken advantage of the biggest strength of a database: You get a multiplayer server for free! Hear me out, we get _a lot_ of stuff traditional game devs have to build themselves for free:\n\n  * Authentication\n  * Concurrency control\n  * Access control\n  * Consistent snapshots of the game state\n  * Binary wire protocol\n\nA separate Python `referee` script drives the shared 35 Hz clock and rotates the map. The player’s clients online supply the input.\n\nThe part I like the most, though, is atomicity: Whenever we run a game tic, we can just say `begin transaction`, and `commit` in the end. Every player (Doom deathmatch supports up to 4) still gets a consistent view, either the way the world looked like before the tic transaction was started, or after it fully committed. No partially applied updates, physics bugs, or disagreements over whether the rocket actually hit.\n\nThe second part that was surprisingly elegant was access control. While sqldoom itself has about 110 tables and just over 100 functions, the four player roles are only allowed to interact with it through a few well-defined API functions. We just revoke access to everything else!\n\nThe `input` function that takes input from a player is a good example:\n    \n    \n    CREATE OR REPLACE FUNCTION api_input(\n      p_fwd real, p_strafe real, p_run boolean, p_turn real,\n      p_fire boolean, p_weapon integer, p_use boolean) RETURNS integer\n    LANGUAGE cedarscript SECURITY DEFINER AS $doom$\n    INSERT INTO mp_inputs\n    SELECT mp.map_id, mp.player_thing_id,\n           LEAST(1.0, GREATEST(-1.0, COALESCE(p_fwd, 0)))::real,\n           LEAST(1.0, GREATEST(-1.0, COALESCE(p_strafe, 0)))::real,\n           [...]\n    FROM mp_players mp WHERE mp.role_name = session_user::text;\n    return 1;\n    $doom$;\n    \n\nWhile the _function_ is allowed to make changes to tables (`security definer`), the player is only allowed to call the function. The only knobs they have is: Forward momentum (`w/s` pressed?), strafe (`a/d` pressed?), are they running?, turning via mouse?, is the fire button pressed?, which weapon is selected?, and do they try to press a button/open a door (`spacebar`)? We don’t even have to trust the player’s input values: The function is clamping the inputs to allowed values.\n\nMultiplayer performance is also surprisingly good: 3 cores per client give stable 35 FPS, and the game tic still stays well below budget. Add an additional core for the tic driver and a 16 core machine is well equipped to run an original `-altdeath` doom deathmatch.\n\nThe public instance rotates through Episode 1 maps with a new map coming up every 10 minutes. If all four slots are occupied, you can still query the live match from the SQL console. [Play, or query the live match →](https://demo.cedardb.cloud/projects/38c0ee59-327d-495e-ad40-735521bba941/doom)\n\n## Bonus: Compiling SQL\n\nSurely a database written in C++ interpreting SQL is insanely inefficient and can’t come close to C? Probably not, but I wanted to evaluate how _far_ off it really is.\n\nCedarDB is a compiling database system: Every complex query is (through multiple steps) lowered to LLVM IR and then compiled to machine code. So I asked myself the question: How does that generated machine code differ from the original compiled linux_doom C code?\n\nComparison between compiled linux_doom and SQLDoom\n\nThe upper half shows an object’s movement logic and how it’s influenced by momentum. The left side is the original doom source code, the right side shows the SQLDoom implementation. The comparison isn’t one-to-one since the logic is spread out a little bit differently, but the C code compiles to 48 instructions while SQLDoom takes 117 instructions. 42 of these additional instructions are actually storing the result in a table again (green lines), which C obviously doesn’t have to do. So it’s worse, don’t get me wrong, but it really isn’t **that** much worse for how many layers of abstraction are usually between SQL and your CPU. For something that started as SQL and passed through a query optimizer before reaching LLVM, I found the gap surprisingly small.\n\n## John Carmack was a genius.\n\nI mean, compare SQLDoom against its Wolfenstein 3D-like predecessor DOOMQL \n\nDOOMQL vs SQLDoom\n\nBoth use the same engine, and same constraints: SQL in, bitmap out. And don’t get me wrong, DOOMQL’s primitive raycasting approach is awesome - much easier to formulate in SQL and not as many dependencies between steps - a much better fit for SQL’s set-based processing.\n\nBut it turns out that the “best fit” is not always the one with the best results. SQLDoom’s BSP-tree approach is much _faster_ and its visual fidelity is a lot _higher_ at the same time. All because John Carmack thought really hard about how much you can get out of your 486 with a little bit of smoke and mirrors.\n\nAnd, to be honest, CedarDB also caught up. Back when I built DOOMQL, the engine was quite a bit slower and we didn’t have a role-based access system yet.\n\n## How to Run it Yourself\n\nIt’s on Github at [github.com/cedardb/sqldoom](https://github.com/cedardb/sqldoom).\n\nYou need three things:\n\n  1. [CedarDB Community Edition](https://cedardb.com/docs/community_edition/),\n  2. Python with `psycopg2` and `pygame`,\n  3. and a Doom IWAD which I can’t give you. The shareware doom1.wad is freely redistributable (`apt install doom-wad-shareware`) and is enough to play episode 1, and the retail WADs work if you own them.\n\nFrom then on just follow the README and you should have your own SQLDoom running in no time!\n\nOr, if that all sounds like too much work, just join a match on the public instance:\n\n[Join a match · 🇪🇺 EU](https://demo.cedardb.cloud/projects/38c0ee59-327d-495e-ad40-735521bba941/doom) [Join a match · 🇺🇸 US](https://demo.cedardb.cloud/projects/f23db33c-f37e-4a28-b73a-1c8151661179/doom)","body_html":"<p><strong>TL;DR:</strong> 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.</p>\n<p>Your browser does not support the video tag.</p>\n<p>SQLDoom in action on an AMD Ryzen 7 7840U</p>\n<p><strong>You can play it right now</strong> Deathmatch, four slots, first come first served.</p>\n<p><a href=\"https://demo.cedardb.cloud/projects/38c0ee59-327d-495e-ad40-735521bba941/doom\" rel=\"nofollow ugc noopener\">SQLDoom on 🇪🇺 EU Servers</a></p>\n<p><a href=\"https://demo.cedardb.cloud/projects/f23db33c-f37e-4a28-b73a-1c8151661179/doom\" rel=\"nofollow ugc noopener\">SQLDoom on 🇺🇸 US Servers</a></p>\n<p>It’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.</p>\n<h1 id=\"sqldoom\">SQLDoom</h1>\n<p>Last year, I published <a href=\"/blog/doomql\">DOOMQL</a> <a href=\"https://github.com/cedardb/doomql\" rel=\"nofollow ugc noopener\">[Github]</a>. 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 <a href=\"https://en.wikipedia.org/wiki/Binary_space_partitioning\" rel=\"nofollow ugc noopener\">BSP trees</a>, which make correct depth ordering cheap enough to afford textures, arbitrary wall angles, and varying floor heights.</p>\n<p>Well I couldn’t let this rest and after some tinkering (you guessed it, parental leave again), I can finally present the <em>real</em> Doom running entirely in SQL.</p>\n<p>One of these is the 1993 binary. The other is a SQL query. Can you figure out which is which?</p>\n<h2 id=\"the-rules\">The rules</h2>\n<p>Let’s first establish a few baseline rules about what we want to achieve:</p>\n<ol><li>It should look like the real Doom. DOOMQL’s visual fidelity is pretty embarrassing in hindsight.</li><li>But more importantly, it also should <em>feel</em> like the real Doom. The original game is just raw <em>fun</em>.</li><li>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.</li><li>The game loop must also be purely SQL-based. It’s okay to use user-defined-functions inside the DB, though.</li><li>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.</li></ol>\n<h2 id=\"architecture\">Architecture</h2>\n<p>Python is delibarately boring (Rule 5). A single script uses <code>pygame</code> 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.</p>\n<pre><code>                         Python\n                 input / timing / display\n                    |              ^\n                    |              |\n          run game tic        request frame\n                    |              |\n                    v              |\n          +----------------+  +----------------+\n          |                |  |                |\n          | SQL game logic |  |  SQL renderer  |\n          |                |  |                |\n          +-------+--------+  +--------+-------+\n                  |                    ^\n                  |                    |\n                  v                    |\n             +-----------------------------+\n             |                             |\n             |       game state tables     |\n             |                             |\n             +-----------------------------+</code></pre>\n<p>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).</p>\n<h2 id=\"loading-the-game-data\">Loading the Game Data</h2>\n<p>Conveniently, Doom’s <a href=\"https://doom.fandom.com/wiki/WAD\" rel=\"nofollow ugc noopener\">.wad file format</a> is actually is highly relational already.</p>\n<p>Two <code>VERTEXES</code> are connected by a <code>LINEDEF</code>, which has two <code>SIDEDEF</code>s. <code>SIDEDEF</code> bound a <code>SECTOR</code> which can have <code>THINGS</code> 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.</p>\n<p>For example, here’s a query rendering E1M1 from a bird’s eye view:</p>\n<pre><code>WITH wall AS (\n  SELECT round((v1.x + (v2.x - v1.x) * t / 32.0) / 48) AS col, -- 48 units per column\n         round((v1.y + (v2.y - v1.y) * t / 32.0) / 96) AS row, -- chars are 2:1\n         l.left_sd_id &lt; 0 AS solid -- one-sided lines are pass-through\n  FROM linedefs l, generate_series(0, 32) AS t        -- walk each line in 32 steps\n  JOIN vertexes v1 ON (v1.map_id, v1.id) = (l.map_id, l.v1_id)\n  JOIN vertexes v2 ON (v2.map_id, v2.id) = (l.map_id, l.v2_id)\n  WHERE l.map_id = 1\n)\nSELECT string_agg(CASE WHEN (col, row) IN (SELECT col, row FROM wall WHERE solid) THEN &#39;#&#39;\n                       WHEN (col, row) IN (SELECT col, row FROM wall)             THEN &#39;.&#39;\n                       ELSE &#39; &#39; END, &#39;&#39; ORDER BY col)\nFROM generate_series(-16, 79) AS col, generate_series(-51, -21) AS row\nGROUP BY row ORDER BY row DESC;</code></pre>\n<p>Output:</p>\n<pre><code>                                                 #####################\n                                                 # ..................#\n                                                 # . ......         .#\n                                                 # . ...... ######  .#\n                                              ######         .. ##  .#\n                                          #####..  .         .. ##  ##\n                                          #  ####### ...... ######  ##########\n                                          # ##   # .                ###..  ..##\n  ################                       ## #    ###.........######## #####.  ##\n###  ........... #                ########..########.........###         #### .##     ######\n#  ..           ##########     ####         ##                 ##        #..... #######    ##\n#  .          #####  ... ##  ###   #.....#..##    ........      ##########..... ....##.#### ##\n#  .     ###.###  ......  ###  .   .        ##  ...      ...             . ......     .#  ##  ##\n#  .     ##..  .  ......  ##   .   .        ##  .           .            . ..........  ##  ## ##\n#  .     ###.######  ...  ######   #.....#..##  ...        ..            #.    ... ..   # ## ##\n#  .           ############    #            ..    ..........             #.......... ...# #  #\n###.........     #             #####     #####                           ##..... ... ##.###  #\n  ################                 #######   ####.........##.##........####### .#######  ### #\n                                                 ###########.#################  #  ####  # # ##\n                                                           #.#      ####......  #  # .   ### ##\n                                                           #.#################  #  ###########\n                                                           ####. .####       #..#\n                                                              #####     ######..######\n                                                                        #   .    ..  #\n                                                                        #   ##...##  #\n                                                                        #   ##   ##  #\n                                                                        ######..######\n                                                                             ####\n                                                                             #####\n                                                                             #.. #\n                                                                             #####</code></pre>\n<h2 id=\"the-game-loop\">The Game Loop</h2>\n<p>It was important to me to actually <em>port</em> Doom, not only render frames that vaguely look like it. Of course, the visuals play a big part in that, but Doom also just <em>feels</em> awesome to play. Take a look at the following scene which is rule 2 in action (me having fun):</p>\n<p>Gibbing 3 soldiers with a rocket launcher</p>\n<p>As you can see, there is a lot going on. Just in this short clip we see:</p>\n<ul><li>Player input has to be polled and processed (walking, turning, shooting),</li><li>enemies walk and attack,</li><li>items are picked up,</li><li>the rocket launcher fires projectiles that move,</li><li>rocket explosions have a blast radius,</li><li>enemy sprites have to be rendered,</li><li>animations, view bobbing, and the HUD</li></ul>\n<p>And 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 <code>1000 ms × 35 Hz = 28.6 ms</code>. It also drew exactly one frame per tic, so it was capped at 35 FPS as well.</p>\n<p>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:</p>\n<ul><li>Running a tic every 28.6 ms (or it will feel just completely wrong)</li><li>Rendering at least 35 frames a second (less is kind of okay, but won’t feel smooth)</li></ul>\n<h3 id=\"the-tic-sequence\">The tic sequence</h3>\n<p>Game 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 <code>cedarscript</code>, it closely resembles PL/pgSQL and allows us to plan beforehand what to do each tic.</p>\n<p>Here is a small section of the tic function:</p>\n<pre><code>doom_cs_clock(map, p);\nlet mut plan = doom_cs_plan(map, p);    -- returns a bitmask of functions to trigger\n\nlet use_queued = doom_tic_use(map, p, plan);\nif (plan &amp; 2) &lt;&gt; 0 OR use_queued { active = doom_cs_activate_specials(map); }\nif (plan &amp; 4) &lt;&gt; 0 OR active &lt;&gt; 0 { doom_cs_doors(map, p); }\n\ndoom_tic_move(map, p);                  -- full movement, or just turning\ndoom_cs_death(map, p);                  -- process deaths\n\nplan = doom_cs_plan(map, p);            -- the world moved; re-plan\nplan = doom_tic_secrets(map, p, plan);  -- secrets, walkover lines, pickups\nplan = doom_tic_weapon(map, p, plan);   -- weapon state, hitscan, damage\n...\nif sound_due { doom_cs_sound(map, p); } -- yes, we also play sounds\ndoom_cs_monsters(map, p);               -- always\ndoom_cs_sector_fx(map, p);              -- always\ndoom_cs_thing_physics(map);             -- always</code></pre>\n<p>The python driver from above calls <code>SELECT doom_run_game_tic(...)</code> every <code>1/35</code> second.</p>\n<p>Each of those called functions then execute a batch of SQL statements. Below is a part of the state machine of the monster AI.</p>\n<pre><code>-- Abridged from sql/runtime/functions/26_cs_monsters.sql.\nWITH RECURSIVE\n  monsters AS ( [...] ),   -- who is alive, what kind, where\n  los      AS ( [...] ),   -- visible, in_view_cone, dist: recursive, walks walls\n  decision AS ( [...] ),   -- one row per actor: its state and what it can see\n  transitions AS (\n    SELECT d.*,\n      CASE\n        WHEN NOT d.alive AND d.state NOT IN (&#39;die&#39;, &#39;dead&#39;, &#39;xdeath&#39;) THEN\n          CASE WHEN d.health &lt; -d.max_health AND d.xdeath_frame IS NOT NULL\n               THEN &#39;xdeath&#39;::actor_state ELSE &#39;die&#39;::actor_state END -- GORY EXPLOSION!\n        WHEN d.state = &#39;stand&#39; THEN\n          CASE WHEN d.visible AND d.in_view_cone AND d.dist &lt;= sight_range\n               THEN &#39;see&#39;::actor_state ELSE &#39;stand&#39;::actor_state END\n        WHEN d.state_tics &gt; 1 THEN d.state          -- animation still running\n        WHEN d.state = &#39;see&#39; THEN\n          CASE WHEN d.visible AND d.dist &lt;= d.attack_range\n                    AND d.attack_cooldown &lt;= 0\n               THEN &#39;missile&#39;::actor_state ELSE &#39;see&#39;::actor_state END\n        [...]                -- die, xdeath, missile, pain, barrel: 5 more\n        ELSE d.state\n      END AS next_state\n    FROM decision d\n  )\nUPDATE monster_ai ai\nSET state = n.next_state, state_tics = n.next_tics, seq_index = n.next_seq,\n    fired_this_tick = n.advances AND n.lands_on_attack_frame\nFROM next_values n\nWHERE ai.map_id = n.map_id AND ai.thing_id = n.thing_id;</code></pre>\n<p>As you can see it encodes the behavior of the clip above: If an enemy takes extreme amounts of damage (<code>CASE WHEN d.health &lt; -d.max_health AND d.xdeath_frame IS NOT NULL</code>) it violently explodes! (<code>THEN &#39;xdeath&#39;::actor_state</code>).</p>\n<h3 id=\"tic-driver-performance\">Tic driver performance</h3>\n<p>Here’s a waterfall rendering of a game tic:</p>\n<p>The slowest game tic I could find</p>\n<p>It’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.</p>\n<p>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!</p>\n<p>To be honest, I was surprised how <em>easy</em> 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!</p>\n<p>Also, it forces you to think differently. Instead of iterating over, e.g., enemies one-by-one you just write a simple <code>UPDATE ... WHERE condition</code> and let the database figure out how to best apply that - in parallel, automatically!</p>\n<p>That also finally made the <a href=\"https://en.wikipedia.org/wiki/Entity_component_system\" rel=\"nofollow ugc noopener\">Entity Component System (ECS)</a> pattern click for me. Here, each <code>entity</code> (player, monster, thing, …) has multiple <code>components</code> (position, sprite, stats, …) and a <code>system</code> (monster ai, move player, damage calculation) decides on how entities with a given set of properties interact with each other. ECS is a lot about data locality and how to iterate over entities that have a given set of components. Well, in SQL we are very used to data intensive processing! Every component becomes a table, and every system becomes an <code>update</code> or <code>insert</code> that just joins the tables it’s interested in with the entity as join key!</p>\n<h2 id=\"rendering\">Rendering</h2>\n<p>Every frame is just a giant view that reads the level geometry and game state plus the player position as input and returns a complete framebuffer. Here’s a sketch of the whole rendering pipeline:</p>\n<pre><code>WITH RECURSIVE\n  render_context AS (SELECT $1 AS map_id, $2 AS player_thing_id, $3 AS difficulty),\n  pos            AS (SELECT $4 AS x, $5 AS y, $6 AS z, $7 AS angle),\n  visible_children AS ( ... ),    -- walk the BSP, culling invisible segments\n  clipped, projected, on_screen,  -- project segments to screen space\n  wall_parts, columns, fragments, -- one row per wall pixel\n  panel_clips, plane_spans, ...,  -- ceiling/floorclip as window functions, visplanes\n  thing_pixels, sprite_fragments, -- sprites\n  fragment_union, resolved,       -- every candidate pixel, resolve for the nearest\n  view_colored, ui_colored,       -- COLORMAP, status bar\n  framebuffer AS ( ... )          -- 64,000 rows of (x, y, rgb)\nSELECT string_agg(rgb, &#39;&#39;::bytea ORDER BY y, x) AS frame_rgb\nFROM framebuffer;                  -- 192,000 bytes, one row</code></pre>\n<p>The implementation is ~1300 lines of SQL (excluding comments) spread across 89 CTEs, so pretty complicated for a SQL query!</p>\n<p>All 89 CTEs of a single rendered frame</p>\n<p>But despite looking like complete insanity, this pipeline is actually pretty close to what Doom does. SQL even has one advantage: The <a href=\"https://github.com/id-Software/Doom/tree/master/linuxdoom-1.10\" rel=\"nofollow ugc noopener\"><code>linux_doom</code> source</a> uses about 3300 lines (excluding comments) for its rendering engine. <em>About 2.5x more lines than SQLDoom</em>. Whether it was a good idea in the first place is a different question, and we’ll talk about that later.</p>\n<p>Let’s first look at the most interesting parts of the rendering pipeline:</p>\n<p>Frame visualization by render stage</p>\n<p>The left half shows bsp-based culling, the right half visualizes wall rendering and visplanes.</p>\n<h3 id=\"bsp-traversal\">BSP traversal</h3>\n<p>Since nobody in 1993 had GPUs with hardware-accelerated <a href=\"https://en.wikipedia.org/wiki/Z-buffering\" rel=\"nofollow ugc noopener\">Z-buffering</a>, Doom had to get occlusion right by drawing in the correct order. The way Doom does it is pretty ingenious: It paints front to back and keeps track of which pixels it already painted (i.e., if I have already drawn a wall pixel, I don’t have to draw the monster behind it). But that’s easier said than done: We need an efficient way to order <em>everything</em> in the level by depth.</p>\n<p>Doom gets this ordering by using precomputed <a href=\"https://en.wikipedia.org/wiki/Binary_space_partitioning\" rel=\"nofollow ugc noopener\">BSP Trees</a> baked into the <code>doom.wad</code> file. Every node of the tree is a line splitting the map in two. The map’s sectors thus get chopped up into a lot of subsectors which are on either side of those lines, and are then inserted into the tree so that we get the following properties:</p>\n<ol><li>each subsector is a leaf and</li><li>each subsector is convex (i.e., you can see any wall from anywhere inside it)</li><li>at every tree node, the entire subtree that is on the camera’s side is guaranteed to be <em>in front of</em> the subtree on the other side.</li></ol>\n<p>By recursively traversing the BSP tree, we thus get a <em>front-to-back</em> order of all subsectors. This gives us the rendering order directly: Once a screen region has been covered by something nearer, objects behind it can be skipped.</p>\n<p>Here’s how this looks like in motion (you might have to view it in full screen):  Your browser does not support the video tag.</p>\n<p>Visualisation of the BSP walk</p>\n<p>On the left, subsectors are ordered front to back, while BSP branches out of view are eagerly culled. In the middle you can see the order that SQLDoom assigns each region. On the right, you see the resulting frame with walls colored according to the subsector they’re in.</p>\n<p>The middle panel shows an optimization SQLDoom makes: For better performance we pre-compute all paths in the BSP tree once at load time. For a given position, every step along such a path is either taking the front (encoded as <code>0</code>), or the back (encoded as <code>1</code>). If we pack these decision into a bigint, and sort that lexicographically (<code>order by</code>), we get the right front to back ordering.</p>\n<pre><code>SELECT ssector_id, ROW_NUMBER() OVER (ORDER BY sort_key) AS bsp_seq\nFROM (\n  SELECT st.ssector_id,\n         -- back = 1 at bit (40 - depth), front = 0.\n         SUM(CASE WHEN st.side = fs.front_side THEN 0::bigint\n                  ELSE (1::bigint &lt;&lt; (40 - st.depth)) END) AS sort_key,\n         BOOL_AND(vc.keep) AS visible   -- was any parent bbox culled?\n  FROM node_path_steps st -- materialized view, every root-to-ssector path\n  JOIN nodes n ON ...\n  CROSS JOIN LATERAL (SELECT ... AS front_side) fs -- on which side are we?\n  JOIN visible_children vc ON ...\n  GROUP BY st.ssector_id\n) s WHERE s.visible;</code></pre>\n<p>One <code>sum() ... order by</code> replaces the whole recursive descent! 40 bits should also be able to handle any map we throw at it: The deepest BSP-Tree is that of E4M8 and has just 32 levels. As long as your maps aren’t larger than 256 times the biggest vanilla map, you’re all sorted!</p>\n<p>If you look carefully, you can see that our bsp traversal also handles culling: Conveniently, every node in the <code>.wad</code> also defines a bounding box of all of its children. If we can prove that our <a href=\"https://en.wikipedia.org/wiki/Viewing_frustum\" rel=\"nofollow ugc noopener\">view frustum</a> is entirely outside of that bounding box, we don’t have to consider that subtree for rendering - that is what <code>visible_children.keep</code> signifies. <code>bool_and(vc.keep)</code> thus drops all subsector where any ancestor doesn’t qualify.</p>\n<p>Everything afterwards in the pipeline is just joined against <code>bsp_seq</code> so only visible subsectors are considered and in the right order.</p>\n<h3 id=\"walls-and-visplanes\">Walls and Visplanes</h3>\n<p>Doom is kind of cheating, it looks 3D, but in reality it’s a 2.5D game. It’s essentially just a flat surface with perfectly vertical walls and ceilings always being parallel to the ground. This makes rendering far easier than in a <em>real</em> 3D engine:</p>\n<ol><li>Paint all walls (front to back, as discussed)</li><li>Everything that isn’t painted yet, is either a floor or a ceiling. Paint that.</li><li>Sprites (monsters, barrels, pickups) are flat images that always face you (think cardboard cutouts), so no complicated transformations here (except for when they overlap a wall, but we’ll get to that).</li></ol>\n<h4 id=\"walls\">Walls</h4>\n<p>A wall occupies a set of contiguous screen columns, and within each column it is a contiguous span of pixels. So we can just paint walls one-by-one, front-to-back by expanding rows and columns via <code>generate_series()</code>:</p>\n<pre><code>columns AS ( -- emit a row per screen column the wall w covers\n  SELECT w.*, x AS col_x, ...\n  FROM wall_parts_tex w\n  CROSS JOIN LATERAL generate_series(\n    GREATEST(0, FLOOR(w.screen_x1)::int),\n    LEAST(screen_w - 1, CEIL(w.screen_x2)::int)) AS x\n),\nfragments AS ( -- one row per pixel the wall covers in this column\n  SELECT c.col_x AS x, y, c.depth_x AS depth, c.u_i, c.v_i\n  FROM clamped_spans c\n  CROSS JOIN LATERAL generate_series(c.y_start, c.y_end) AS y\n)</code></pre>\n<p>Doom uses two loops instead: <a href=\"https://github.com/id-Software/Doom/blob/a77dfb96cb91780ca334d0d4cfd86957558007e0/linuxdoom-1.10/r_segs.c#L206\" rel=\"nofollow ugc noopener\"><code>R_RenderSegLoop</code></a> to get the screen columns and <a href=\"https://github.com/id-Software/Doom/blob/a77dfb96cb91780ca334d0d4cfd86957558007e0/linuxdoom-1.10/r_draw.c#L105\" rel=\"nofollow ugc noopener\"><code>R_DrawColumn</code></a> to draw the pixels.</p>\n<p>Rendering the walls cost us on average 1.7 ms.</p>\n<h4 id=\"visplanes\">Visplanes</h4>\n<p>Now that we have the walls out of the way, let’s talk about the fun part: The floors and ceilings, what Doom calls <em>visplanes</em>.</p>\n<p>Unfortunately, Doom’s rendering algorithm doesn’t translate to SQL nearly as well since it’s highly imperative: Doom keeps two arrays, <code>ceilingclip</code> and <code>floorclip</code> which have one entry per screen column. They mark the band in each column that is still open (i.e., has to become floor or ceiling and hasn’t been painted yet) Whenever a new wall is painted, they are <em>mutated</em> until every pixel is filled. Not only does Doom mutate them, but it’s also very important to mutate them in <em>the right order</em>. It’s ingenious! In the end it’s just a flood fill algorithm, but everything looks 3D basically for free (in C, that is).</p>\n<p>SQLDoom has to approach this problem differently, as we don’t have the concepts of loops or mutable state in SQL. So instead of looping, we turn to sorting and aggregating over those sorted runs - a poor man’s loop!</p>\n<p>The things we iterate over here are called <em>panels</em> : One part of a wall appearing in one column of the screen. Some panels draw something: a solid wall (<code>solid</code>), the wall above a door (<code>upper</code>), or the wall part below a window or a parapet (<code>lower</code>), some panels are just there to influence how other panels are rendered: If you step out of a door below a balcony, there’s something above you and that has to end <em>somewhere</em>.</p>\n<p>So for each screen column (<code>col_x</code>) we have an ordered list of panels from near to far. The clip state before a panel is thus defined entirely by the row preceding it. Do I smell window functions?</p>\n<p>Since this is pretty hard to explain in text, let’s watch a video instead!  Your browser does not support the video tag.</p>\n<p>Determining the position of visplanes with window functions</p>\n<p>Here’s the (abbreviated) SQL query:</p>\n<pre><code>panel_clips AS (\n  -- 1. the band as the NEARER panels left it\n  SELECT p.*,\n    COALESCE(MAX(CASE WHEN part IN (&#39;solid&#39;,&#39;upper&#39;,&#39;upper_flush&#39;)\n                      THEN y_bot::int + 1 END) OVER w, 0)            AS cc_before,\n    COALESCE(MIN(CASE WHEN part IN (&#39;solid&#39;,&#39;lower&#39;,&#39;lower_down&#39;)\n                      THEN y_top::int - 1 END) OVER w, screen_h - 1) AS fc_before\n  FROM panel_seq p\n  WINDOW w AS (PARTITION BY col_x ORDER BY depth_x, bsp_seq, part, seg_id\n               ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)\n),\nplane_spans_raw AS (\n  -- 2. whatever the band leaves uncovered is a ceiling above the wall...\n  SELECT col_x, fsec AS sector_id, f_ceil AS plane_z, &#39;ceil&#39; AS plane,\n         cc_before         AS y0,   -- from where nearer walls stopped\n         f_ceil_y::int - 1 AS y1    -- down to this panel&#39;s own ceiling\n  FROM panel_clips\n  WHERE part IN (&#39;solid&#39;,&#39;upper&#39;,&#39;upper_open&#39;,&#39;upper_flush&#39;)\n    AND f_ceil_y::int - 1 &gt;= cc_before          -- nothing left open: skip\n  UNION ALL\n  -- ...and a floor below it\n  SELECT col_x, fsec, f_floor, &#39;floor&#39;,\n         f_floor_y::int AS y0,      -- from this panel&#39;s own floor\n         fc_before      AS y1       -- down to where nearer walls stopped\n  FROM panel_clips\n  WHERE ...\n)</code></pre>\n<p>We first calculate for every panel in the scene that potentially renders some pixels how much of the column is still unassigned. And the only pixels that already <em>could</em> be assigned are from all the panels closer (that’s the <code>ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING</code> term in (1)). <em>Then</em> we draw some pixels from the end of the previous panel until the beginning of the next panel (2). We do this both for ceilings and floors.</p>\n<p>A pretty hacky way to disguise an imperative algorithm as set-based, right? Good thing we have window functions…</p>\n<p>Rendering floors, ceilings and the sky typically costs about <code>3 ms</code>.</p>\n<h4 id=\"the-ugly-part\">The ugly part</h4>\n<p>Unfortunately, I had to lie to you: Walls, visplanes and sprite resolution don’t <em>draw</em> anything yet. They just emit candidates of the form <code>(x, y, depth, colour)</code> with potentially many pixels at the same position, but at different depths: Since we don’t implement Doom’s fixed-point arithmetic, we could have different walls, floors and skies overlapping. Also, we have to render sprites, which in turn could be partially occluded by walls. Doom does a very tightly choreographed dance to make sure this can never happen, so that they don’t have to do z-buffering. I tried and failed to reproduce that choreography in SQL, so I gave up and used the brute force method instead: Just generate everything and then pick winners.</p>\n<pre><code>((LEAST(depth, 131071.0) * 4096)::bigint &lt;&lt; 34) -- depth, clamped to 17.12 fixed-point\n| ((2 - surface_priority) &lt;&lt; 32)                -- wall &gt; sprite &gt; plane\n| (LEAST(source_priority, 3) &lt;&lt; 30)\n| ((stable_id + 32768) &lt;&lt; 14)                   -- stable tiebreak\n| (light_index &lt;&lt; 8) | palette_index            -- the payload\nAS winner_key\n...\nSELECT pix, MIN(winner_key) FROM ranked_fragments GROUP BY pix</code></pre>\n<p>It’s the same trick as with the BSP tree where we just pack everything into a bigint, and then select the min: The most significant bits are depth, so we can just choose the min to find the winner. And since the payload (i.e., the color of the pixel) is also part of the key, we don’t even have to join again! Seems a bit hacky, but since this is per-pixel work (and a single Doom frame has <code>320*200=64000</code> pixels), we have to be careful to not do too much work.</p>\n<p>Even with this optimization, it’s still the most expensive part of the frame: <code>8.2</code> milliseconds on average, more than a third of the entire frame! And that’s exactly why John Carmack avoided that. But we’re lucky to now have machines that can run this even in SQL and still hit the 35 FPS target. <a href=\"https://www.youtube.com/watch?v=ta41xU-tkFA\" rel=\"nofollow ugc noopener\">The future is now, old man!</a>.</p>\n<h3 id=\"rendering-performance\">Rendering Performance</h3>\n<p>Here’s a waterfall view of the pipeline compared against Doom’s 35 FPS frame target. </p>\n<p>The rendering pipeline on an AMD Ryzen 7 7840U</p>\n<p>On my Laptop (Ryzen 7 PRO 7840U) I typically get about 60 FPS, but it drops down to 35 FPS on very busy scenes.</p>\n<p>The most expensive parts of the pipeline are (unsurprisingly):</p>\n<ul><li>rendering visplanes (where we have to emulate an iterative algorithm),</li><li>depth resolve (which the original Doom successfully avoids in the first place),</li><li>and everything that has to happen per pixel (e.g., colormap lookup, packing the framebuffer)</li></ul>\n<h2 id=\"where-using-a-database-is-actually-a-good-idea\">Where using a database is <em>actually</em> a good idea</h2>\n<p>Rendering Doom in a database is obviously a bad idea. But there <em>are</em> a few areas where it’s actually a good fit and I’m going to defend them to my death!</p>\n<h3 id=\"everything-is-data\">Everything is data</h3>\n<p>I previously didn’t expect how much I’d enjoy translating properties of items into a relational data set. For one, it makes it really easy to see what your game actually contains, but most importantly it’s also really easy to change.</p>\n<p>The player’s shotgun is just a row:</p>\n<pre><code>doom=# SELECT name, ammo_type, ammo_per_shot, pellet_count,\ndoom-#        dmg_dice_count, dmg_dice_mult, max_range\ndoom-#   FROM weapon_defs WHERE name = &#39;shotgun&#39;;\n  name   | ammo_type | ammo_per_shot | pellet_count | dmg_dice_count | dmg_dice_mult | max_range\n---------+-----------+---------------+--------------+----------------+---------------+-----------\n shotgun | shells    |             1 |            7 |              3 |             5 |      2048\n(1 row)</code></pre>\n<p>Seven pellets, each doing <code>3d5</code> damage.</p>\n<p>Even the animation is data! Here’s the entire state machine of the shotgun:</p>\n<pre><code>doom=# SELECT state, seq_index AS seq, frame, tics,\ndoom-#        is_attack_frame AS shoots, refire_check AS refire\ndoom-#   FROM weapon_frames WHERE weapon_id = 3 ORDER BY state, seq_index;\n state | seq | frame | tics | shoots | refire\n-------+-----+-------+------+--------+--------\n ready |   0 | A     |    1 | f      | f\n fire  |   0 | A     |    3 | f      | f\n fire  |   1 | A     |    7 | t      | f\n fire  |   2 | B     |    5 | f      | f\n fire  |   3 | C     |    5 | f      | f\n fire  |   4 | D     |    4 | f      | f\n fire  |   5 | C     |    5 | f      | f\n fire  |   6 | B     |    5 | f      | f\n fire  |   7 | A     |    3 | f      | t\n fire  |   8 | A     |    7 | f      | f\n flash |   0 | A     |    4 | f      | f\n flash |   1 | B     |    3 | f      | f\n(12 rows)</code></pre>\n<p>Properties of things just being stored in a table also makes it <em>really</em> easy to mod <em>everything</em>. Take a look at the following clip where I’m frustrated I’m not doing enough damage, and just mod the shotgun to shoot 500 pellets at once at a higher spread!</p>\n<p>Chea...Modding the shotgun</p>\n<p>Of course, we <em>could</em> have also just stored everything in files in e.g. JSON but that means</p>\n<ol><li>constraints aren’t verified at modification time and</li><li>We’d have to reload for changes to take effect.</li></ol>\n<h3 id=\"multiplayer-almost-comes-for-free\">Multiplayer almost comes for free</h3>\n<p>Well, now we went through all of this hassle to port over Doom to SQL and haven’t even taken advantage of the biggest strength of a database: You get a multiplayer server for free! Hear me out, we get <em>a lot</em> of stuff traditional game devs have to build themselves for free:</p>\n<ul><li>Authentication</li><li>Concurrency control</li><li>Access control</li><li>Consistent snapshots of the game state</li><li>Binary wire protocol</li></ul>\n<p>A separate Python <code>referee</code> script drives the shared 35 Hz clock and rotates the map. The player’s clients online supply the input.</p>\n<p>The part I like the most, though, is atomicity: Whenever we run a game tic, we can just say <code>begin transaction</code>, and <code>commit</code> in the end. Every player (Doom deathmatch supports up to 4) still gets a consistent view, either the way the world looked like before the tic transaction was started, or after it fully committed. No partially applied updates, physics bugs, or disagreements over whether the rocket actually hit.</p>\n<p>The second part that was surprisingly elegant was access control. While sqldoom itself has about 110 tables and just over 100 functions, the four player roles are only allowed to interact with it through a few well-defined API functions. We just revoke access to everything else!</p>\n<p>The <code>input</code> function that takes input from a player is a good example:</p>\n<pre><code>CREATE OR REPLACE FUNCTION api_input(\n  p_fwd real, p_strafe real, p_run boolean, p_turn real,\n  p_fire boolean, p_weapon integer, p_use boolean) RETURNS integer\nLANGUAGE cedarscript SECURITY DEFINER AS $doom$\nINSERT INTO mp_inputs\nSELECT mp.map_id, mp.player_thing_id,\n       LEAST(1.0, GREATEST(-1.0, COALESCE(p_fwd, 0)))::real,\n       LEAST(1.0, GREATEST(-1.0, COALESCE(p_strafe, 0)))::real,\n       [...]\nFROM mp_players mp WHERE mp.role_name = session_user::text;\nreturn 1;\n$doom$;</code></pre>\n<p>While the <em>function</em> is allowed to make changes to tables (<code>security definer</code>), the player is only allowed to call the function. The only knobs they have is: Forward momentum (<code>w/s</code> pressed?), strafe (<code>a/d</code> pressed?), are they running?, turning via mouse?, is the fire button pressed?, which weapon is selected?, and do they try to press a button/open a door (<code>spacebar</code>)? We don’t even have to trust the player’s input values: The function is clamping the inputs to allowed values.</p>\n<p>Multiplayer performance is also surprisingly good: 3 cores per client give stable 35 FPS, and the game tic still stays well below budget. Add an additional core for the tic driver and a 16 core machine is well equipped to run an original <code>-altdeath</code> doom deathmatch.</p>\n<p>The public instance rotates through Episode 1 maps with a new map coming up every 10 minutes. If all four slots are occupied, you can still query the live match from the SQL console. <a href=\"https://demo.cedardb.cloud/projects/38c0ee59-327d-495e-ad40-735521bba941/doom\" rel=\"nofollow ugc noopener\">Play, or query the live match →</a></p>\n<h2 id=\"bonus-compiling-sql\">Bonus: Compiling SQL</h2>\n<p>Surely a database written in C++ interpreting SQL is insanely inefficient and can’t come close to C? Probably not, but I wanted to evaluate how <em>far</em> off it really is.</p>\n<p>CedarDB is a compiling database system: Every complex query is (through multiple steps) lowered to LLVM IR and then compiled to machine code. So I asked myself the question: How does that generated machine code differ from the original compiled linux_doom C code?</p>\n<p>Comparison between compiled linux_doom and SQLDoom</p>\n<p>The upper half shows an object’s movement logic and how it’s influenced by momentum. The left side is the original doom source code, the right side shows the SQLDoom implementation. The comparison isn’t one-to-one since the logic is spread out a little bit differently, but the C code compiles to 48 instructions while SQLDoom takes 117 instructions. 42 of these additional instructions are actually storing the result in a table again (green lines), which C obviously doesn’t have to do. So it’s worse, don’t get me wrong, but it really isn’t <strong>that</strong> much worse for how many layers of abstraction are usually between SQL and your CPU. For something that started as SQL and passed through a query optimizer before reaching LLVM, I found the gap surprisingly small.</p>\n<h2 id=\"john-carmack-was-a-genius\">John Carmack was a genius.</h2>\n<p>I mean, compare SQLDoom against its Wolfenstein 3D-like predecessor DOOMQL </p>\n<p>DOOMQL vs SQLDoom</p>\n<p>Both use the same engine, and same constraints: SQL in, bitmap out. And don’t get me wrong, DOOMQL’s primitive raycasting approach is awesome - much easier to formulate in SQL and not as many dependencies between steps - a much better fit for SQL’s set-based processing.</p>\n<p>But it turns out that the “best fit” is not always the one with the best results. SQLDoom’s BSP-tree approach is much <em>faster</em> and its visual fidelity is a lot <em>higher</em> at the same time. All because John Carmack thought really hard about how much you can get out of your 486 with a little bit of smoke and mirrors.</p>\n<p>And, to be honest, CedarDB also caught up. Back when I built DOOMQL, the engine was quite a bit slower and we didn’t have a role-based access system yet.</p>\n<h2 id=\"how-to-run-it-yourself\">How to Run it Yourself</h2>\n<p>It’s on Github at <a href=\"https://github.com/cedardb/sqldoom\" rel=\"nofollow ugc noopener\">github.com/cedardb/sqldoom</a>.</p>\n<p>You need three things:</p>\n<ol><li><a href=\"https://cedardb.com/docs/community_edition/\" rel=\"nofollow ugc noopener\">CedarDB Community Edition</a>,</li><li>Python with <code>psycopg2</code> and <code>pygame</code>,</li><li>and a Doom IWAD which I can’t give you. The shareware doom1.wad is freely redistributable (<code>apt install doom-wad-shareware</code>) and is enough to play episode 1, and the retail WADs work if you own them.</li></ol>\n<p>From then on just follow the README and you should have your own SQLDoom running in no time!</p>\n<p>Or, if that all sounds like too much work, just join a match on the public instance:</p>\n<p><a href=\"https://demo.cedardb.cloud/projects/38c0ee59-327d-495e-ad40-735521bba941/doom\" rel=\"nofollow ugc noopener\">Join a match · 🇪🇺 EU</a> <a href=\"https://demo.cedardb.cloud/projects/f23db33c-f37e-4a28-b73a-1c8151661179/doom\" rel=\"nofollow ugc noopener\">Join a match · 🇺🇸 US</a></p>","headings":[{"level":1,"text":"SQLDoom","id":"sqldoom"},{"level":2,"text":"The rules","id":"the-rules"},{"level":2,"text":"Architecture","id":"architecture"},{"level":2,"text":"Loading the Game Data","id":"loading-the-game-data"},{"level":2,"text":"The Game Loop","id":"the-game-loop"},{"level":3,"text":"The tic sequence","id":"the-tic-sequence"},{"level":3,"text":"Tic driver performance","id":"tic-driver-performance"},{"level":2,"text":"Rendering","id":"rendering"},{"level":3,"text":"BSP traversal","id":"bsp-traversal"},{"level":3,"text":"Walls and Visplanes","id":"walls-and-visplanes"},{"level":3,"text":"Rendering Performance","id":"rendering-performance"},{"level":2,"text":"Where using a database is _actually_ a good idea","id":"where-using-a-database-is-actually-a-good-idea"},{"level":3,"text":"Everything is data","id":"everything-is-data"},{"level":3,"text":"Multiplayer almost comes for free","id":"multiplayer-almost-comes-for-free"},{"level":2,"text":"Bonus: Compiling SQL","id":"bonus-compiling-sql"},{"level":2,"text":"John Carmack was a genius.","id":"john-carmack-was-a-genius"},{"level":2,"text":"How to Run it Yourself","id":"how-to-run-it-yourself"}]}}