DOOMQL: A DOOM-like multiplayer shooter in pure SQL

I recently stumbled across Patrick’s excellent DOOM clone running in a browser powered by DuckDB-WASM. Ever since I’ve read that, I wanted to push his awesome idea to the logical extreme: Build a multiplayer DOOM-like shooter entirely in SQL with CedarDB doing all the heavy lifting. During a month of parental leave (i.e., a lot of sleepless nights), I tried exactly that.

Here’s a sneak peek at DOOMQL:

DOOMQL in action

Okay, with the flashy demo out of the way, let’s talk about details. What follows is a tour of the architecture, the SQL rendering pipeline, the game loop, and the fun metagame of cheating by issuing SQL commands against the database.

Why even do this?

Playing DuckDB DOOM in your browser is fun, but some things bugged me: First of all, having parts of the rendering pipeline in Javascript felt like cheating. It worked well for DuckDB-Doom where everything is contained in a single HTML page, but I wanted to see if I could do everything in SQL. DuckDB-Doom is also a little bit stuttery with just 8 frames per second and has a pretty tiny viewport. I wanted to see if I could speed that up by switching over to CedarDB. I also wanted real sprites with transparency and they should move around believably in 3D space. And most importantly, making the game multi-player should not just be possible, but easy, right? I got nerd-sniped by the perceived similarity of a database server to a traditional game server: Databases exist to synchronize shared state across clients. Thanks to transaction isolation, each player has a consistent view of the game world, no matter what the other clients are doing. Why not lean into that? I would love to lie to you and claim I did it all to push CedarDB as an awesome database system but to be honest the database nerd in me just wanted to turn all knobs up to 11 and see what breaks.

Architectural overview

At a high level

  • State lives in tables (map, players, mobs, inputs, configs, sprites, …)
  • Rendering is a stack of SQL views that implement raycasting and sprite projection
  • The game loop is a tiny shell script that executes a SQL file ~ 30 times per second.
  • The client is ~ 150 lines of Python: It polls for input and queries the database for your 3D view.

You can play, observe other players and even cheat (by sending raw SQL).

Game state, or: Let’s store everything in the database

With a database at hand, it’s natural to store all game configuration, state, and static data in the database:

Config:

CREATE TABLE config(
  player_move_speed NUMERIC DEFAULT 0.3, 
  player_turn_speed NUMERIC DEFAULT 0.2,
  ammo_max INT DEFAULT 10,
  ammo_refill_interval_seconds INT DEFAULT 2
  );

Map:

CREATE TABLE map(x INT, y INT, tile CHAR);

Players and inputs:

CREATE TABLE players (
  id INT REFERENCES mobs(id),
  score INT DEFAULT 0,
  hp INT DEFAULT 100,
  ammo INT DEFAULT 10,
  last_ammo_refill int default EXTRACT(EPOCH FROM (now()))::INT
);

CREATE TABLE inputs(
  player_id INT PRIMARY KEY REFERENCES players(id),
  action CHAR(1), -- 'w', 'a', 's', 'd', 'x' for shooting
  timestamp TIMESTAMP DEFAULT NOW()
);

Because everything is data, modding a running match is trivial:

-- Change a setting
update config set ammo_max = 20;

 -- Add a player
insert into players values (...);

-- Move forward
update input set action = 'w' where player_id = <your_id>;

 -- Cheat (pls be smarter about it)
update players set hp = 100000 where player_id = <your_id>;

-- Ban cheaters (that weren't smart about it)
delete from players where hp > 100;

Renderer: When a VIEW becomes your 3D view

If you squint enough, in DOOM, a 3D (or more correct: 2.5D) view is just a view over 2D state (i.e., the level map and any players/enemies on it). Well, we’ve got VIEWS in SQL as well. They’re also just views on our (2D) state tables. What’s stopping us from quite literally building a 3D “view” of our 2D map using a simple raycasting algorithm?

The pipeline:

  1. Send a set of rays from each player’s eye into the world, and see which map tiles are visible
  2. Check which walls the player sees, rendering them at the correct height and more or less solid based on the distance
  3. Project mobs into the player’s camera space
  4. Select sprite LODs based on depth
  5. Expand sprites into pixels, scaled to screen space
  6. Occlude against walls and other sprites
  7. Assemble frame buffer rows with string_agg
  8. Build a minimap reusing the visible tiles calculation from earlier
  9. Combine the 3D view with minimap and HUD (HP/bullets/players) into a game view

Let’s take a more in-depth look at steps 2, 7, and 8.

Raycasting

The recursive ray‑marching logic is adapted from Patrick’s DuckDB DOOM post. Here is a simplified excerpt, adapted for multiplayer:

CREATE OR REPLACE VIEW visible_tiles AS  
WITH RECURSIVE raytrace AS (  
  -- Starting at the player's eye ...
  SELECT r.player_id, r.col, 1 AS step_count,  
         r.player_x + COS(r.angle)*s.step AS fx,  
         r.player_y + SIN(r.angle)*s.step AS fy,  
         r.angle, 0 AS dist  
  FROM rays r, settings s  -- rays are built in an earlier step
  UNION ALL  
  -- ... we recursively march along the rays, 1 "step" at a time ...
  SELECT rt.player_id, rt.col, rt.step_count + 1,  
         rt.fx + COS(rt.angle)*s.step,  
         rt.fy + SIN(rt.angle)*s.step,  
         rt.angle,  
         step_count * s.step * COS(rt.angle - m.dir) AS dist  
  FROM raytrace rt, settings s, players p, mobs m  
  WHERE rt.step_count < s.max_steps   -- ... stopping after our max render distance
    AND rt.player_id = p.id  
    AND m.id = p.id  
    AND NOT EXISTS (  -- or if we hit a wall
      SELECT 1 FROM map m  
      WHERE m.x = CAST(rt.fx AS INT) AND m.y = CAST(rt.fy AS INT)  
        AND m.tile = '#')  -- wall
)  
-- We then determine per player:
--  a) which tiles we hit
--  b) how far away these tiles are
--  c) the column of the screen each tile should correspond to
SELECT player_id, tile, CAST(fx AS INT) AS tile_x, CAST(fy AS INT) AS tile_y, col, MIN(dist) AS dist  
FROM raytrace rt, map m  
WHERE m.x = CAST(rt.fx AS INT) AND m.y = CAST(rt.fy AS INT)  -- We might hit the same tile multiple times, so we take the closest hit
GROUP BY player_id, tile_x, tile_y, tile, col;  

And that’s just the first step in the pipeline. For the rest, take a look at the code.

Final frame assembly

After all the heavy lifting, the payoff is surprisingly simple:

SELECT player_id, y, string_agg(ch, '' ORDER BY x) AS row  
FROM framebuffer  
GROUP BY player_id, y;  

This glues together character pixels into text rows.

HUD + minimap

The same trick builds the HUD and minimap. Here is the health bar:

'HP: [' ||
repeat('█', LEAST(20, ROUND(20 * GREATEST(0, LEAST(p.hp,100))::numeric / 100)::int)) ||
repeat(' ', GREATEST(0, 20 - ROUND(20 * GREATEST(0, LEAST(p.hp,100))::numeric / 100)::int)) ||
'] ' || GREATEST(0, p.hp)

Add ammo dots with repeat('•', p.ammo) and you’ve got a HUD entirely in SQL:

 1: Lukas      (L) score: 1   HP: [█████████           ] 50    AMMO: ••••••••••
 2: Foobar     (F) score: 0   HP: [████████████████████] 100   AMMO: ••••••••  

We can also re-use our earlier visible_tiles view to build a minimap with a view cone:

select * from minimap where player_id = 1 order by y;

 player_id | y  |                               row                                
-----------+----+------------------------------------------------------------------
         1 |  0 | ################################################################
         1 |  1 | ################################################################
         1 |  2 | ##.......      #####               #############################
         1 |  3 | ##.....F.      #####               #####                     ###
         1 |  4 | ##.......      #####               #####                     ###
         1 |  5 | ##  .....      #####               #####                     ###
         1 |  6 | ##   ...                                                     ###
         1 |  7 | ##    .L                                                     ###
         1 |  8 | ##             #####               #####                     ###
         1 |  9 | ##             #####               #####                     ###
         1 | 10 | ##             #############  ##########                     ###
         1 | 11 | ##########  ################  ##########                     ###
         1 | 12 | ##########  ################  ##########                     ###
         1 | 13 | ##########  ################  ######################  ##########
         1 | 14 | ####                 #######  ######################  ##########
         1 | 15 | ####                 #######  ######################  ##########
         1 | 16 | ####                 #####             #####                 ###
         1 | 17 | ####                 #####             #####                 ###
         1 | 18 | ####                 #####             #####                 ###
         1 | 19 | ####                 #####             #####                 ###
         1 | 20 | ####                 #####             #####                 ###
         1 | 21 | ####                                   #####                 ###
         1 | 22 | ####                                                         ###
         1 | 23 | ####                 #####                                   ###
         1 | 24 | ####                 #####             #####                 ###
         1 | 25 | ####                 #####             #####                 ###
         1 | 26 | ####                 #####             #####                 ###
         1 | 27 | ####                 #####             #####                 ###
         1 | 28 | ####                 #####             #####                 ###
         1 | 29 | ################################################################
         1 | 30 | ################################################################
         1 | 31 | ################################################################

The surprisingly elegant game loop

The loop is just a shell script running raw SQL against the database:

# Game loop @ 30 ticks per second
while true; do
  psql -qtAX -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -p "$DB_PORT" -f gameloop.sql
  sleep 0.03
done

Inside gameloop.sql, actions like bullet movement, collisions, kills, and respawns run in a single transaction, which keeps state consistent even if something fails mid-tick.

Here’s the part processing interactions with bullets:

-- Process all bullets
BEGIN TRANSACTION;

-- Move bullets forward
UPDATE mobs 
SET x = x + cos(dir) * 0.5, y = y + sin(dir) * 0.5 
WHERE kind = 'bullet';

-- Delete bullets that are out of bounds
DELETE FROM mobs 
WHERE (x < 0 
OR x >= (select max(x) from map) 
OR y < 0 
OR y >= (select max(y) from map))
AND kind = 'bullet';

-- Delete bullets that hit walls
DELETE FROM mobs b 
WHERE EXISTS 
    (SELECT 1 
    FROM map m 
    WHERE m.x = CAST(b.x AS INT) 
    AND m.y = CAST(b.y AS INT) 
    AND m.tile = '#') 
AND kind = 'bullet';


-- Players hit by a bullet loses 50 HP
UPDATE players p SET hp = hp - 50
FROM collisions c
WHERE p.id = c.player_id;

-- If a player has 0 or less HP, the player killing them gets a point
UPDATE players p SET score = score + 1
FROM collisions c
WHERE p.id = c.bullet_owner
AND EXISTS (SELECT 1 FROM players p2 WHERE p2.id = c.player_id AND p2.hp <= 0);

-- Delete bullets that hit players
DELETE FROM mobs m
USING collisions c
WHERE m.id = c.bullet_id;

-- Respawn players whose HP is 0 or less
UPDATE mobs m
SET x = r.x, y = r.y, dir = 0
FROM players p
CROSS JOIN (
  SELECT x, y
  FROM map
  WHERE tile = 'R'
  ORDER BY random()
  LIMIT 1
) AS r
WHERE m.id = p.id
  AND p.hp <= 0;

-- Reset players' HP to 100 and ammo to 10 after respawn
UPDATE players p SET
  hp = 100,
  ammo = 10
FROM mobs m
WHERE p.id = m.id
AND p.hp <= 0;

COMMIT;

On my machine, the game loop takes about 1 ms, so we could defintely improve the tick rate. That might be a way to get the Counterstrike snobs who scoff at everything below 128 Hz. It would require some refactoring on my part since I tied the movement speed to the game loop - a big no no in game design!

While only someone insane could think a pure SQL raycasting renderer is a good idea in an actual game, I’ll happily defend this transactional game loop. I don’t think this part would be much more concise or less brittle in a real game engine.

Make it multiplayer in two queries

The game client’s job description is simple:

  1. Render
SELECT full_row FROM screen WHERE player_id = <your_id> ORDER BY y
  1. Send input
INSERT INTO inputs(player_id, action)
    VALUES (<your_id>, <pressed_key>)
    ON CONFLICT(player_id)
    DO UPDATE SET action = EXCLUDED.action

The game loop periodically checks the input table and moves all players accordingly - inside a transaction, of course, so we don’t run into any race conditions.

That’s it (well, plus a one-time “create player” on first connect). The ~150 lines of Python in the client mostly handle keyboard input and reducing terminal flicker. Bonus: The client provides an observer mode. All it has to do is swap the <player_id> in the render call.

Performance

At 128 x 64 pixels, a single player view takes ~33 ms on my machine, which is enough for a breezy ~30 FPS, compared to DuckDB DOOM’s 8 FPS at just 32 x 16 pixels. I’m actually quite proud of that performance and quite happy with CedarDB here. I don’t think any other database system can keep up with that. Let me know if you find one!

You might worry that rendering the views of all players and filtering late would be very wasteful. CedarDB’s query optimizer pushes the where player_id = <...> predicate through view boundaries, avoiding unncessary work. You can easily check by running:

select * from screen order by y; -- render both users
-- Time: 57,907 ms (~2x single player 33ms)

The cheating metagame

Because clients send raw SQL as superusers (I didn’t bother setting up any role based access control or row level security, don’t do that!), there’s an emergent metagame: Cheat creatively and try not to get caught.

Low effort:

update players set score = 0 where id != <your_id>;
update players set hp = 0 where id != <your_id>;

Mischievous:

update inputs set action = null where player_id != <your_id>;

Steal kills:

update mobs set owner = <your_id> where kind = 'bullet';

Attempted but didn’t work:

DELETE FROM mobs m
USING collisions c
WHERE m.id = c.bullet_id AND c.player_id = <your_id>;

This doesn’t work because moving bullets, checking for collisions, and respawn happens in the same transaction. As transactions are atomic, you either see everything being applied at once, or nothing. By the time you see the hit, you’re already dead. A property that’s very useful for database systems (and not just to prevent cheating).

What I learned

I set out to see if I could push Patrick’s demo to an extreme: Doing the entire rendering pipeline in SQL. And while it works, I have to admit that it is a pretty… bad idea? Fast enough, but horrible to maintain and debug.

The surprise was how natural it felt to express game state and logic in SQL. It even felt like accidentally re-invented the entity-component-system pattern.
And multiplayer “just worked” because the database system which handles all the nasty concurrency is the source of truth.

Try it yourself!

All the code is on Github: DOOMQL Repo

Run:

docker pull cedardb/cedardb:latest
docker run --rm -p 5432:5432 -e CEDAR_PASSWORD=postgres --detach cedardb/cedardb:latest
# Wait a few seconds for CedarDB to start
./server.sh

# in a second terminal window, zoom way out to have no line wraping issues
python3 pyclient.py

Want to discuss DOOMQL with me or find like-minded database nerds? Join our Community Slack