https://www.hey.earth/posts/duckdb-doom Abusing DuckDB-WASM by making SQL draw 3D graphics (Sort Of) Sun Apr 20 2025*duckdbsqlwasmdoom Back 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
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)
* Pain Point: My first attempts using standard CDN links for the
worker script just flat-out failed with net::ERR_ABORTED 404.
Debugging WASM loading issues in the browser isn't always
intuitive.
* The Fix: Digging into the DuckDB-WASM docs revealed the more
robust initialization pattern: using their helper functions
(getJsDelivrBundles) or explicitly selecting a bundle (mvp for
max compatibility), creating the worker via URL.createObjectURL
(new Blob(...)), and using the +esm CDN endpoint for the main
module import.
// 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
* Pain Point: Muscle memory from SQLite/MySQL led me to use
AUTOINCREMENT for the bullets table ID. DuckDB promptly slapped
me with a Parser Error: syntax error at or near "AUTOINCREMENT".
* The Fix: Remembering that DuckDB adheres more closely to standard
SQL sequences. This meant CREATE SEQUENCE my_seq; and then CREATE
TABLE ... (id INTEGER PRIMARY KEY DEFAULT nextval('my_seq'),
...).
-- 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)
* Pain Point: This one drove me nuts for a while. I tried using
generate_series(0, settings.view_w - 1) inside my rendering VIEW.
The binder freaked out with errors like Table function cannot
contain subqueries and even Conversion Error: Could not convert
string 's.view_w' to INT32.
* The Fix: I had to restructure the view logic significantly.
Instead of generating the exact range needed, I generated a
fixed, oversized range (like 0-255) first, then added another CTE
layer to filter that oversized range using the actual view_w from
the settings CTE.
-- 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
* Pain Point: My game loop was simple: setInterval(async () => {
await tick(); await render(); }, 150). But because tick() and
render() involved async database calls, sometimes a new interval
would fire before the previous one finished. This was most
obvious with the temporary collisions table used for bullet hits
- I'd get rapid-fire "table collisions does not exist!" followed
by "table collisions already exists!" errors.
* The Fix: A classic solution: a simple boolean lock
(isProcessingTick). The interval callback now checks this flag;
if true, it bails immediately. If false, it sets the flag, runs
the async work in a try...finally, and clears the flag in the
finally block, ensuring it's always released.
// 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)
* Pain Point: The SQL view rendered walls/floor/ceiling beautifully
(well, beautifully for text mode). But enemies and bullets were
just data. Drawing them required knowing if they were hidden by a
wall.
* The Fix: A hybrid approach combining SQL and JavaScript. I
created another SQL view (column_distances) specifically to
output the distance to the nearest wall for each screen column:
-- 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:
* SQL query optimization and execution planning
* Raycasting algorithms and 3D projection
* Asynchronous JavaScript patterns
* The capabilities and limitations of WASM in the browser
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