The internet's favorite question. We've seen DOOM running on calculators, ATMs, pregnancy tests, and smart refrigerators. The meme endures because DOOM is the universal benchmark for "does this thing actually compute?"
But this isn't that. This isn't running DOOM. This is Will Dart run DOOM? A complete source port from C to pure Dart. No C bindings. No FFI. No WebAssembly wrapper around the original executable. No dosbox emulator that then runs doom on top. Every single pixel pushed by Dart code.
To understand why I attempted this, we need to go back to the 90s.
Picture a teenager hunched over grid paper and maths books, trying to reverse-engineer how DOOM worked. No internet. No Stack Overflow. No YouTube tutorials. Just pure curiosity and growing frustration. I knew something impossible was happening on that 486 - proper 3D graphics on hardware that had no business rendering 3D anything. I could see the result, but I couldn't understand the mechanism.
John Carmack and id Software had performed what felt like genuine magic. Every frame was a miracle of mathematics I desperately wanted to comprehend. I filled notebooks with failed attempts at understanding perspective projection, spent hours trying to figure out how walls could be different heights, how floors and ceilings worked. I didn´t crack it until years later.
Fast forward to 2025, and I can now just ask an AI for a complete, detailed breakdown of exactly how something works. The contrast is almost absurd. Thirty years of wondering, and now the answer is a conversation away.
So I decided to find out if Dart could run DOOM. Not by embedding C code - by writing a complete renderer in pure Dart. With an AI as my pair programmer.
Let me be clear about what we built, because "running DOOM" means different things to different projects.
This is NOT:
This IS:
The architecture was deliberate. We split the project into three packages:
doom_core: The pure Dart game engine. Zero dependencies beyond the Dart SDK itself. It takes TicCmd input (the player's commands: forward, back, turn, fire) and outputs a Uint8List framebuffer - 320 by 200 pixels of indexed colour, exactly like the original.
doom_math: Fixed-point arithmetic, angle tables, trigonometric lookups. Also zero dependencies. This is where the gnarly bit-shifting lives.
doom_wad: WAD file parsing, texture compositing, map loading. The glue between the original game data and our renderer.
The Flutter app? It's a thin shell. It loads the WAD file, captures keyboard and touch input, and paints the framebuffer to the screen. The real work happens in pure Dart.
This separation wasn't just architecture for architecture's sake. It was designed from day one to make AI-assisted development possible: clear boundaries, testable outputs, verifiable correctness. When Claude Code is working on the renderer, it doesn't need to understand Flutter widgets or platform channels. It just needs to understand how pixels get into a byte array.
DOOM was the perfect challenge for what I wanted to explore.
The original C source code (linuxdoom-1.10, open-sourced by id Software in 1997) is elegant and readable. It's not modern C - no fancy abstractions, no design patterns - but it's clear. You can follow the logic. The entire game fits in roughly 40,000 lines. Ambitious but achievable.
More importantly, DOOM is a masterclass in technical constraints. No floating-point arithmetic (most PCs didn't have FPUs). Fixed-point maths everywhere. Binary Space Partitioning for rendering. Lookup tables instead of runtime calculations. Every optimisation was born from necessity, and understanding those constraints teaches you something profound about software engineering.
And yes, there was personal meaning. This was the magic I couldn't crack as a teenager. Completing the circle felt right.
Why Dart, though?
Dart is quietly exceptional for AI-assisted development. The tooling is outstanding: dart fix automatically applies common corrections, dart analyse catches errors before runtime, and formatters like very_good_analysis ensure consistent code regardless of who - or what - writes it. When an AI generates code, Dart's tooling immediately tells you if something's wrong.
Strong typing catches errors that would silently corrupt state in a dynamic language. The type system is your safety net.
And Dart deploys everywhere. The same codebase runs on iOS, Android, desktop, Linux, and web. No rewrites. No platform-specific logic bleeding into your core code. AOT compilation produces compact, performant native binaries with minimal dependencies. JIT keeps development iteration fast.
When you're vibe coding - guiding an AI through intent rather than writing every line manually - you need tooling that catches mistakes immediately. Dart delivers.
Before we could port DOOM, we had to understand it. And understanding DOOM's renderer is understanding a 1993 miracle of optimisation.
Binary Space Partitioning is the insight that made DOOM possible. John Carmack didn't invent BSP - it was a known technique in computer graphics - but he figured out how to apply it to real-time game rendering on consumer hardware.
The idea: during map compilation, split the level into convex subsectors using a binary tree. At runtime, traverse that tree based on the player's position. The tree structure guarantees that you process sectors in back-to-front order, meaning you can render without a depth buffer.
On a 486, every cycle mattered. Z-buffering - the technique used by most modern 3D graphics - would have been too expensive. BSP made real-time 3D possible by pre-computing the hard work.
DOOM doesn't use floating-point numbers. At all.
Most PCs in 1993 didn't have floating-point units. The i387 coprocessor was expensive, and software floating-point emulation was slow. So DOOM uses 16.16 fixed-point arithmetic: 16 bits for the integer part, 16 bits for the fractional part.
In this system, 65536 represents 1.0 - the value "one" is stored as 2^16. To multiply two fixed-point numbers, you multiply them as integers and shift right by 16 to get the result back into the correct range. Division shifts left first, then divides. Overflow is a constant concern.
This sounds simple until you actually implement it. Sign extension, wraparound behaviour, the specific ways that C handles bit shifts on signed integers - all of these matter. Getting them wrong means walls render in the wrong place, or physics breaks, or the player clips through geometry.
DOOM's angle system is elegant. A full circle equals 2^32 units - the entire range of an unsigned 32-bit integer. A right angle (90 degrees) is exactly one quarter of that range. Turning left adds to the angle; turning right subtracts. When you go past a full circle, the integer naturally wraps around to zero.
This means angle arithmetic just works using integer operations. Adding angles wraps correctly. Comparing angles is straightforward. And since you're working with integers, not floats, there's no accumulated precision error.
Pre-computed lookup tables map angles to sine and tangent values. Instead of calculating trigonometry at runtime - expensive on 1993 hardware - you just use the angle to index into an array and read the answer.
DOOM's demo system is brutally elegant. Every "random" number in the game comes from a fixed table of 256 values, read sequentially. Monster behaviour, damage variation, sound timing - all deterministic. Player inputs are recorded 35 times per second: how fast you're moving, which direction you're turning, which buttons you're pressing.
To play back a demo, you feed those stored commands into the game engine. If your implementation is correct, the playback is frame-perfect identical to the original recording. If a single calculation differs from the original C - one bit wrong in one angle, one slightly different movement speed - the demo drifts out of sync, and eventually the player walks through walls in an alternate timeline.
Demo sync is the ultimate correctness test. If your port can play back original DOOM demos perfectly, you've matched the original behaviour.
This wasn't a full-time project. It was evening sofa time, background tasks between client work, the occasional focused weekend. About a month of elapsed time, maybe two weeks of actual effort if you added up the hours.
Day one was foundation: WAD parsing, package structure, Flutter shell. We built the WAD Explorer first - an interactive tool to browse the contents of DOOM's data files. Seeing the textures, sprites, and map structures for the first time in our own tool was satisfying. E1M1 loaded correctly. The data was accessible.
Then came rendering.
BSP traversal started working. Walls appeared on screen. They were wrong - stretched, positioned incorrectly, flickering at certain angles - but they were there. Something recognisable. The dopamine hit of seeing DOOM geometry rendered by your own code is hard to describe.
The first major bug we called "wall wobble." As the player moved, walls appeared to undulate, shifting position subtly with each frame. It looked like the whole world was breathing.
The culprit: using a fast distance approximation instead of proper trigonometry for wall distance calculations. The original DOOM code includes a quick-and-dirty distance function for cases where precision doesn't matter - it's faster but less accurate. We were using it somewhere that required exact values.
This taught us the first important lesson: AI could write code that compiled and ran, but fixing subtle bugs required understanding why the original code did things a specific way. Reading the C wasn't optional. Understanding the intent behind each function was essential.
A few weeks in, everything worked perfectly on desktop and mobile. macOS, iOS, Android - all rendering correctly. Physics felt right. Monsters behaved as expected.
Then we deployed to web.
The web build was a disaster. Walls disappeared at certain viewing angles. Physics calculations produced bizarre results. The player would clip through geometry that worked fine on native platforms.
The investigation took several evenings spread across a week.
JavaScript doesn't have native 32-bit integers. Dart's int type is 64-bit signed, but when compiled to JavaScript, it becomes an IEEE 754 double-precision floating-point number. Most of the time this doesn't matter. But DOOM's code relies on specific 32-bit overflow and wraparound behaviour.
Here's the problem: DOOM stores angles as 32-bit unsigned integers, where the full circle is 2^32. To look up the sine of an angle, you shift it right to get an index into a pre-computed table. But what happens when that angle represents a leftward direction - stored as a large unsigned value near the top of the 32-bit range?
On a 32-bit system, the number stays in the valid range - it's just a big positive integer that indexes correctly into the table. On a 64-bit system, Dart interprets it as a signed negative number, and when you shift right, you get sign extension - the negative sign propagates, giving you a completely wrong (and often negative) table index. On JavaScript, you're doing floating-point arithmetic that happens to produce integers most of the time but subtly fails at these edge cases.
The fix required adopting a Dart package that provides a proper 32-bit integer type, guaranteeing wraparound semantics on all platforms. We added helper methods for cases requiring unsigned interpretation. We copied the exact trigonometry lookup tables from DOOM's original source code to ensure perfect compatibility.
But this crisis demonstrated something important: faithful ports require deep understanding. You can't just mechanically translate C to Dart. You need to understand what the C code assumes about its execution environment.
Throughout the project - often during lunch breaks or waiting for builds - I realised that vibe coding works best when you can prove the AI's output is correct. So we built verification systems alongside the features.
Frame Capture: Export any rendered frame to PNG. When sprites looked wrong, we could capture the frame and inspect pixel-by-pixel. Compare against screenshots from the original DOOM. Spot differences visually. We can also use this for golden tests to catch regressions.
The WAD Explorer: An interactive tool to browse WAD contents. Hex viewer for raw lump data. Image viewer for textures and sprites. Data tables for map geometry. When you're debugging why a texture looks wrong, you need to see exactly what data you're working with.
Integration Tests: Load the real DOOM1.WAD. Position the player at known coordinates. Render a frame. Assert the framebuffer matches expected output. When these tests pass, the renderer is correct. When they fail, you know exactly where to look.
Demo Sync Testing: Record player input as demos. Play back. Verify frame-perfect sync. This caught dozens of subtle issues: RNG not being reset on level load, movement splitting calculations differing from original, angle calculations using the wrong shift amount.
The insight: AI works best when you can prove its output is correct. Build the proof systems first. When Claude suggests a fix, run the tests. If they pass, the fix is probably correct. If they fail, you have specific feedback to provide.
Vibe coding isn't magic. It's a collaboration with specific strengths and weaknesses.
Where Claude excelled:
Translating C to Dart. Given original DOOM code, Claude could produce syntactically correct Dart that followed our conventions. The mechanical work of converting one language to another is exactly what AI handles well.
Implementing well-documented algorithms. BSP traversal, texture mapping, collision detection - these are understood problems with clear specifications. Claude could implement them accurately.
Adding boilerplate. DOOM has hundreds of monster definitions, state machine entries, and action functions. Tedious to write manually. Perfect for AI generation.
Catching type errors and suggesting fixes. When the analyser complained, Claude could usually propose a correct solution.
Where human insight was essential:
Knowing what to say. This sounds simple, but it's the core skill. You don't need perfect grammar or eloquent prose. A rough sentence with the right keywords - "check the fixed point angle calculations are not overflowing or wrapping differently" - gets you further than a beautifully written prompt that misses the technical point. The AI responds to specificity, not polish.
Understanding why original code used specific approaches. Claude could tell you what the code does, but understanding why a particular optimisation was chosen - and whether our context requires the same trade-off - needed human judgement.
Recognising when behaviour differed from original. When something looked "almost right" but slightly off, identifying the discrepancy required comparing against the original game, understanding what correct behaviour should look like, and tracing through code with that expectation.
Debugging sign extension and overflow issues. The 32-bit versus 64-bit problem was fundamentally about platform assumptions embedded in code. Understanding those assumptions required reading the original C with platform semantics in mind.
The most surreal development session happened over Christmas, about a month into the project. Sitting in the passenger seat while my dad drove, I directed bug fixes from my phone.
The setup: a remote VPS running Claude Code in a tmux session. SSH from my mobile. The game running in a browser tab on the same phone.
No laptop. No desk. Just a terminal and an AI.
I'd see a bug in the browser - monsters not activating correctly. Switch to the terminal. Describe the issue to Claude. Read the diffs. Approve the changes. Refresh the browser. Fixed.
There's something almost absurd about debugging a 1993 game engine from a car seat in 2025, using technology that would have seemed like science fiction when DOOM first shipped. But it worked. Several hours of otherwise dead travel time became productive debugging sessions.
This isn't just about convenience. It represents a fundamental shift: vibe coding doesn't require a workstation. If you have a terminal and can describe what's wrong, you can make progress. The project moved forward in stolen moments - evening sofa sessions, lunch breaks, passenger seats on motorways.
Once the core renderer was working, I wanted that authentic 90s CRT look. In most frameworks, adding a post-processing shader would mean learning platform-specific graphics APIs. Different implementations for OpenGL, Metal, Vulkan, WebGL. Significant complexity and platform-specific bugs.
In Flutter? A breeze.
Write a single GLSL fragment shader. Load it with FragmentShader.compile(). Apply it to the framebuffer texture. Done. Works on all platforms.
We implemented four display modes:
Same shader code runs on iOS, Android, desktop, and web. No platform-specific rendering code. Full GPU acceleration everywhere. The declarative widget system made toggling modes trivial.
This is a perfect example of why Flutter isn't just "cross-platform UI." It's a real rendering engine. When you need to push pixels, it delivers.
Let's be honest about the current state.
What's Working:
What's Not Working (Yet):
Demo Playback Desync:
The original DOOM's demo system is brutally unforgiving. Every random number, every movement calculation, every angle shift must be identical to the original C. One bit wrong and the demo drifts, then the player walks through walls in an alternate timeline.
I've fixed dozens of these issues. Unsigned angle handling. Movement splitting. RNG reset on level load. But original C-recorded demos still desync after a few minutes. Somewhere in our code, one calculation differs from the original.
Finding it is like searching for a specific grain of sand on a beach made of 32-bit integers.
This is the unsolved puzzle. The white whale. Demo sync is the ultimate correctness test, and I´m not there yet.
Sound:
No audio. In Dart noone can hear you scream. We've implemented sound propagation (alerting monsters when you fire), but actual audio playback hasn't been tackled. The original DOOM's sound system is its own complex beast - DMX library, MUS format music, sound priorities and channels.
It's on the list (and has spawned a whole other 8-bit synth project side-side quest!).
Level Editor:
The natural next step. A visual editor for creating DOOM levels, running in the browser, using the same pure Dart renderer for live previews. Drag-and-drop sectors. Paint textures. Place monsters. Export standard WAD files playable in any DOOM port.
The architecture supports this. The same doom_core renderer that runs the game can render editor previews. No separate codebase needed.
The Real Dream: A Retro DOOM-Era MMO
Here's where it gets ambitious. Imagine persistent worlds with that 1993 aesthetic. Hundreds of players in connected sectors. The id Tech 1 engine reimagined for multiplayer at scale.
The rendering is solved - that's what this project proves. The networking, server infrastructure, game design, persistent world state - those are the unsolved problems.
And, as it turns out, funding a retro MMO isn't something Claude can help with. Yet.
This is where the pure Dart architecture pays off again. The same doom_core package could drive both single-player and server-side game logic. No rewriting. No porting. The separation of concerns wasn't just for testing - it was for dreams we hadn't articulated yet.
Whether these dreams ship is a matter of time, money, and stubbornness. But the foundation is there.
These are just dreams, before AI certain to stay as just dreams, but maybe now, finally theres a chance they can become reality. This is what has got me so excited about AI.
On John Carmack and id Software:
The original DOOM code is over 30 years old, and it's still elegant. Comments are sparse because the code is clear. Variable names tell you what they do. Functions are focused.
Performance constraints bred innovation. When you can't brute-force a problem, you find clever solutions. BSP trees, fixed-point math, the angle system - each was born from limitation and became something beautiful.
Finally seeing inside the magic machine that teenage-me couldn't crack was worth the effort. The mystery is solved, and understanding it only increases my admiration.
On Vibe Coding:
AI doesn't replace understanding - it amplifies it. The more you know, the better your prompts. The better your prompts, the better the output. Knowledge compounds.
Tools for verification are as important as tools for generation. If you can't prove the AI's output is correct, you're just hoping. Hope isn't engineering.
The future of coding is collaboration, not replacement. Human insight for the why, AI assistance for the what.
On Dart:
Proven as a serious systems language. This isn't a toy project - it's a full game engine, rendering in real-time, across every platform.
Tooling makes AI-generated code trustworthy. When dart analyse passes, you know the code is at least syntactically correct and type-safe. That's a powerful foundation.
Cross-platform deployment is genuinely painless. The same code runs on my phone, my laptop, and in your browser.
The Contrast:
In the 1990s: grid paper, maths books, no internet, pure determination, never cracking the problem.
In 2025: Claude Code, instant answers, still requiring understanding but with dramatically better tools for acquiring it.
The question changed from "how does this work?" to "does my understanding match reality?"
Progress.
BTW you might have thought the video above was a video....... its not its doom running. try giving it focus and pressing ESC key, you can play it, or click below for full screen.
The game runs in your browser.
Sound is coming. Demo sync is the white whale. A level editor is the next milestone. And maybe, someday, that retro MMO.
But the real victory isn't the deployed game. It's finally understanding how DOOM works, 30 years after first wondering.
Will Dart run DOOM?
Yes. Every pixel.