Abusing DuckDB-WASM by making SQL draw 3D graphics (Sort Of)

duckdbsqlwasmdoom

Building a SQL-Powered Doom Clone in the Browser

I had this slightly crazy idea: Could I ditch most of the conventional JavaScript game loop and rendering logic and build a 3D game engine where SQL queries did the heavy lifting? Naturally, I decided to try building a primitive, text-based Doom clone to see how far I could push it using DuckDB-WASM.

A screenshot of the text-based Doom clone, showing the 3D view and minimap

Spoiler: It kind of works, it was often painful, but I learned a ton about the surprising power (and quirks) of running an analytical database engine in the browser for tasks it was definitely not designed for.

The Setup: SQL Isn't Just for SELECT * Anymore

Forget managing game state in JavaScript objects or drawing pixels with Canvas/WebGL. My approach looked like this:

  1. The Database is the World: The 16x16 map, player coordinates (x, y, dir), enemy/bullet positions, game settings – everything lives in DuckDB tables, right there in the browser tab.

    -- MAP: Creating a 16x16 world with walls around the edges
    CREATE TABLE map(x INT, y INT, tile CHAR);
    INSERT INTO map (x,y,tile)
      SELECT x,y,'#' FROM range(0,15) AS t(x) CROSS JOIN (VALUES(0),(15)) AS y(y)
    UNION ALL
      SELECT x,y,'#' FROM (VALUES(0),(15)) AS x(x) CROSS JOIN range(1,14) AS t(y);
    INSERT INTO map (x,y,tile)
      SELECT x,y,'.' FROM range(1,14) AS t1(x) CROSS JOIN range(1,14) AS t2(y);
    -- Add some interior walls
    UPDATE map SET tile = '#' WHERE (x,y) IN ((5,5),(6,5),(7,5),(8,5),(5,10),(6,10),(7,10),(8,10));
     
    -- PLAYER: Starting position in the middle of the map
    CREATE TABLE player(x DOUBLE, y DOUBLE, dir DOUBLE, icon CHAR DEFAULT '@');
    INSERT INTO player(x,y,dir) VALUES (8.5,8.5,0);
  2. SQL Dictates Reality:

    • Want to move forward? UPDATE player SET x = x + COS(dir)*step, y = y + SIN(dir)*step;
    • Bullet hits a wall? DELETE FROM bullets WHERE EXISTS (SELECT 1 FROM map WHERE ...)
    • Enemy fragged? A JOIN between bullets and enemies followed by DELETE statements.
    -- Bullet physics and collision in pure SQL
    -- Move all bullets forward based on their direction vectors
    UPDATE bullets SET x = x+dx, y = y+dy;
     
    -- Delete bullets that hit walls
    DELETE FROM bullets 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 = '#'
    );
     
    -- Create a temporary table for bullet-enemy collisions
    CREATE TEMP TABLE collisions AS 
    SELECT b.id AS bullet_id, e.id AS enemy_id 
    FROM bullets b 
    JOIN enemies e ON CAST(b.x AS INT) = CAST(e.x AS INT) AND CAST(b.y AS INT) = CAST(e.y AS INT);
     
    -- Remove hit enemies and their bullets
    DELETE FROM enemies WHERE id IN (SELECT enemy_id FROM collisions);
    DELETE FROM bullets WHERE id IN (SELECT bullet_id FROM collisions);
  3. The Renderer is a SQL VIEW: This is where it gets wild. I defined a SQL VIEW named render_3d_frame that actually performs raycasting and renders the 3D scene. This beast uses recursive CTEs to cast rays for each screen column, calculates wall distances (with fish-eye correction!), determines the height of the wall slice for that column, and then uses string_agg to stitch together the characters (' ', ., , , , ) for each row of the final text frame.

    Here's the core of the raycasting algorithm in SQL:

    -- This recursive CTE casts rays until they hit a wall or reach max distance
    raytrace(col, step_count, fx, fy, angle) AS (
        -- Initial ray positions starting from player
        SELECT r.col, 1, p.x + COS(r.angle)*s.step, p.y + SIN(r.angle)*s.step, r.angle 
        FROM rays r, p, s 
        
        UNION ALL 
        
        -- For each ray, keep stepping until we hit a wall or max steps
        SELECT rt.col, rt.step_count + 1, rt.fx + COS(rt.angle)*s.step, 
               rt.fy + SIN(rt.angle)*s.step, rt.angle 
        FROM raytrace rt, s 
        WHERE rt.step_count < s.max_steps 
        AND NOT EXISTS (
          -- Stop when 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 = '#'
        )
    ),
    -- Find the first hit for each column
    hit_walls AS (
        SELECT col, MIN(step_count) as min_steps 
        FROM raytrace rt 
        WHERE EXISTS (
          SELECT 1 FROM map m 
          WHERE m.x = CAST(rt.fx AS INT) 
          AND m.y = CAST(rt.fy AS INT) 
          AND m.tile = '#'
        ) 
        GROUP BY col 
    ),

    Yes, SQL is calculating perspective and drawing characters. DuckDB's recursive CTE capabilities are unexpectedly powerful for this kind of work.

  4. JavaScript Glues It Together (and Handles Sprites): My JS code became the orchestrator. It handles keyboard input, runs the setInterval game loop, calls the SQL view to get the background frame, then fetches entity (bullet/enemy) positions and pre-calculated wall distances (from another SQL view!). It performs a quick Z-buffer check in JS to see if a sprite is closer than the wall at its projected screen column, draws it onto the background frame if it is, and finally outputs the resulting text onto a <pre> tag.

    // Render function showing the Z-buffer sprite handling
    async function render3d() {
      try {
        // Fetch all rendering data in parallel for performance
        const [frameResult, distanceResult, bulletResult, enemyResult, playerResult, settingsResult] = 
          await Promise.all([
            conn.query(`SELECT y, row FROM render_3d_frame ORDER BY y;`),
            conn.query(`SELECT x, dist_corrected FROM column_distances ORDER BY x;`),
            conn.query(`SELECT id, x, y FROM bullets;`),
            conn.query(`SELECT id, x, y, icon FROM enemies;`),
            conn.query(`SELECT x, y, dir FROM player LIMIT 1;`),
            conn.query(`SELECT fov, view_w, view_h FROM settings LIMIT 1;`)
        ]);
        
        // Convert to JS arrays and calculate distance lookup table
        const backgroundRows = frameResult.toArray().map(r => r.row);
        const wallDistances = distanceResult.toArray().reduce((acc, row) => { 
            acc[row.x] = row.dist_corrected; return acc; 
        }, {});
        const bullets = bulletResult.toArray();
        const enemies = enemyResult.toArray();
        const player = playerResult.get(0);
        const settings = settingsResult.get(0);
        
        // Create mutable frame buffer from SQL background
        const frameBuffer = backgroundRows.map(row => row.split(''));
        
        // Combine entities for rendering
        const entities = [ 
            ...bullets.map(b => ({ ...b, type: 'bullet', icon: '*' })), 
            ...enemies.map(e => ({ ...e, type: 'enemy' })) 
        ];
        
        // Sort back-to-front for proper Z ordering
        entities.sort((a, b) => (
            Math.hypot(b.x - player.x, b.y - player.y) - 
            Math.hypot(a.x - player.x, a.y - player.y)
        ));
        
        // 3D projection calculations
        const cosDir = Math.cos(-player.dir);
        const sinDir = Math.sin(-player.dir);
        const projectionFactor = settings.view_w / (2 * Math.tan(settings.fov / 2));
        
        // Draw each entity with Z-buffer check
        for (const entity of entities) {
            const dx = entity.x - player.x;
            const dy = entity.y - player.y;
            const depth = dx * cosDir - dy * sinDir;
            
            if (depth <= 0.1) continue; // Behind player or too close
            
            const horizontalOffset = dx * sinDir + dy * cosDir;
            let screen_x = Math.round(settings.view_w / 2 + 
                                     (horizontalOffset / depth) * projectionFactor);
            
            if (screen_x < 0 || screen_x >= settings.view_w) continue; // Off screen
            
            const drawY = Math.floor(settings.view_h / 2);
            const finalY = Math.max(0, Math.min(settings.view_h - 1, drawY));
            
            // Z-buffer check: only draw if entity is closer than wall
            const wallDist = wallDistances[screen_x] !== undefined ? 
                            wallDistances[screen_x] : Infinity;
            
            if (depth < wallDist) {
               frameBuffer[finalY][screen_x] = entity.icon;
            }
        }
        
        // Update display with completed frame
        screenEl.textContent = frameBuffer.map(row => row.join('')).join("\n");
      } catch (renderError) {
         console.error("Error during render3d:", renderError);
      }
    }

Essentially, I took DuckDB-WASM – designed for fast analytics – and coerced it into acting like a state machine and a rudimentary rendering pipeline.

The Gauntlet: My Battles with Bugs, Binders, and Browsers

This wasn't exactly a smooth ride. Here's a log of some of the more... memorable... challenges and the fixes that eventually worked:

1. The Initial Roadblock: DuckDB-WASM Just Wouldn't Load (404s)

// My solid initialization pattern after multiple false starts
import * as duckdb from 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/+esm';
 
// Get available bundles and select the MVP (most compatible) one
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
let bundle = JSDELIVR_BUNDLES['mvp'];
 
// Create worker from bundle using Blob URL
const workerUrl = URL.createObjectURL(
    new Blob(
        [`importScripts("${bundle.mainWorker}");`], 
        { type: 'text/javascript' }
    )
);
const worker = new Worker(workerUrl);
 
// Create DuckDB instance with proper error handling
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
URL.revokeObjectURL(workerUrl); // Clean up

The lesson: When working with WASM libraries, always follow the recommended initialization patterns from the library authors.

2. SQL Dialect Gotchas: AUTOINCREMENT vs. SEQUENCE

-- The DuckDB way to handle auto-incrementing IDs
DROP SEQUENCE IF EXISTS bullets_id_seq;
CREATE SEQUENCE bullets_id_seq START 1;
CREATE TABLE bullets( 
  id INTEGER PRIMARY KEY DEFAULT nextval('bullets_id_seq'), 
  x DOUBLE, y DOUBLE, dx DOUBLE, dy DOUBLE 
);

This highlights an important point about DuckDB: it's not just SQLite in the browser. It has its own SQL dialect with nuances from PostgreSQL and standard SQL.

3. Fighting the Query Planner (Binder Errors & Table Functions)

-- Inside the rendering VIEW definition
 
-- This works: Separate generation and filtering
potential_cols AS ( 
  SELECT col FROM generate_series(0, 255) AS gs(col)  -- Generate 0-255
), 
cols AS ( 
  SELECT pc.col FROM potential_cols pc, s WHERE pc.col < s.view_w  -- Filter later
),
 
-- ... rest of view uses the filtered 'cols' ...

I also initially forgot to alias the output of generate_series, leading to Referenced column "value" not found errors. Fixed with generate_series(...) AS gs(col).

This approach satisfied the query planner, even though it's less elegant. It taught me that SQL query planners have strict rules about how and when references can be resolved, especially with table-generating functions.

4. The Dreaded async/setInterval Race Condition

// The game loop with race condition protection
let isProcessingTick = false;
setInterval(async () => {
  if (isProcessingTick) return; // Prevent overlap
  isProcessingTick = true;
  try {
    await tickBullets(); // Async DB stuff
    await render3d();   // Async DB stuff + JS
    await renderMinimap(); // More async DB stuff
  } catch (intervalError) {
    console.error("Error in game loop:", intervalError);
  } finally {
    isProcessingTick = false; // Always release lock
  }
}, 150); // ~6-7 FPS

This was a classic reminder that asynchronous timing with recurring events needs careful handling, especially when database operations are involved.

5. Sprites: Beyond the SQL Background (Z-Buffer Logic)

-- VIEW FOR COLUMN DISTANCES (for depth buffer)
CREATE VIEW column_distances AS
WITH RECURSIVE
s AS (SELECT * FROM settings LIMIT 1),
p AS (SELECT * FROM player LIMIT 1),
potential_cols AS ( SELECT col FROM generate_series(0, 255) AS gs(col) ),
cols AS ( SELECT pc.col FROM potential_cols pc, s WHERE pc.col < s.view_w ),
rays AS ( SELECT c.col, (p.dir - s.fov/2.0 + s.fov * (c.col*1.0 / (s.view_w - 1))) AS angle 
          FROM cols c, s, p ),
raytrace(col, step_count, fx, fy, angle) AS ( /* same as render_3d_frame */ ),
hit_walls AS ( /* same as render_3d_frame */ )
SELECT
    c.col AS x,
    COALESCE(hw.min_steps * s.step * COS(r.angle - p.dir), s.max_steps * s.step) AS dist_corrected
FROM cols c
LEFT JOIN hit_walls hw ON c.col = hw.col
JOIN s ON TRUE
JOIN rays r ON c.col = r.col JOIN player p ON TRUE;

Then, in my JavaScript render3d function, I performed the Z-buffer check by comparing entity depth to wall depth for each screen column.

Performance and Results

How did it actually run? Surprisingly well, considering what we're asking SQL to do. On a modern laptop, I get about 6-7 FPS with the 150ms game loop interval. The most expensive operation is the SQL raycasting view, which takes about 80-100ms to execute. The sprite rendering in JavaScript is quite fast in comparison.

A GIF showing gameplay with player movement and shooting

Here's what the game looks like in action. The main view shows the 3D perspective with text-based graphics, while the smaller box in the corner shows a top-down minimap. You can see how the walls are rendered with different characters based on distance, giving a primitive 3D effect.

The movement feels responsive enough, and the SQL-based collision detection works well. There's something strangely satisfying about mowing down enemies with SQL DELETE statements.

Pushing SQL to Its Limits: What I Learned

This experiment taught me several important lessons about both SQL and browser-based development:

  1. SQL is surprisingly powerful for non-traditional use cases. It's not just for data retrieval. The combination of recursive CTEs, window functions, and aggregate functions makes complex algorithms possible.

  2. DuckDB-WASM is impressively performant. Running an analytical database engine in the browser that can handle complex recursive queries 6-7 times per second is no small feat.

  3. The boundaries between languages can be blurred. This project combined SQL for game state and rendering fundamentals, with JavaScript for orchestration and sprite handling. Neither could have done the job alone.

  4. Debugging across language boundaries is challenging. When something went wrong, it wasn't always clear if the issue was in the JavaScript, the SQL, or at the interface between them. I added extensive logging to track the flow between components.

  5. Query planning is a complex art. I had to work around many limitations of how SQL planners work, especially around table function evaluation and CTEs.

Would I Recommend This Approach?

For a production game? Absolutely not. It's a fun hack, but there are much better tools for game development.

But as a learning exercise? 100% yes. This project forced me to think deeply about:

Try It Yourself!

If you want to experiment with this SQL-powered monstrosity yourself, I've put the full source code on GitHub (opens in a new tab). It's about 500 lines of code total, with roughly half being SQL and half JavaScript.

I'd love to see how far others can push this concept. Could you add textures? Implement a more complex game world? Add enemies that move and shoot back? The SQL rabbit hole goes deep!

What's Next?

This experiment has me wondering what other unconventional uses might exist for DuckDB-WASM in the browser. Physics simulations? Path finding algorithms? Full-text search engines?

Sometimes the most interesting projects come from using tools in ways they were never intended to be used. What weird DuckDB-WASM experiment would you like to see next?

not made by a 🤖