Destino: Doom in Your Terminal, Powered by Node.js FFI

Node.js TSC Member, Principal Engineer at Platformatic, Polyglot Developer. RPG and LARP addicted and nerd on lot more. Surrounded by lovely chubby cats.
Destino lets you play Doom right in your terminal using Node.js.
It might sound like a joke, and that’s how it began. At the Node Collaborator Summit in London, Paolo made the classic DOOM comment. Matteo and Luca decided to run with it, turning the joke into a real project. The name was an easy choice: in Italian, “destino” means “doom”.
Destino brings together node:ffi, doomgeneric, and OpenTUI. JavaScript controls the main loop, while the Doom engine runs natively. OpenTUI handles turning the framebuffer into terminal graphics, and sound is managed by DoomGeneric’s SDL2 audio backend. You can also package everything as a Node.js Single Executable Application (SEA), bundling the JavaScript, native libraries, WAD, and sound font.
Why build this?
First, because it’s funny. Seeing Doom run at 35 fps in a terminal, with sound, powered by Node.js FFI, is the kind of thing that grabs people’s attention.
But what’s the real point we were trying to prove here?
For a long time, calling native code from Node.js meant writing a native addon, spawning a subprocess, using WebAssembly, or putting the native code behind a service. These options still work, but they add serious complexity and overhead, changing how you build, package, deploy, or debug your program.
FFI offers Node.js another way: you load a native library, describe the ABI, and call it straight from JavaScript.
Doom is a great way to test this idea. It needs a steady game loop, keyboard input, native memory, framebuffer access, assets, audio, and proper cleanup so your terminal isn’t left in a bad state. If Node.js can handle all that, FFI becomes much more concrete.
And yes, if Node.js can run Doom like this, it can probably call the C library buried in your enterprise stack too.
The shape of the program
Destino uses doomgeneric as the engine. The project builds it as a native shared library with a small C platform layer. That layer exposes only what JavaScript needs:
Initialize Doom with a WAD file and sound font
Advance the engine one tick
Send key press and release events
Return the framebuffer pointer
Report when a frame is ready
Clean up native resources
Node.js loads the shared library with node:ffi:
const {
lib,
functions: {
init,
doomgeneric_Tick: tick,
send_key: sendKey,
get_framebuffer: getFramebuffer,
frame_ready: frameReady,
clear_frame_ready: clearFrameReady,
cleanup
}
} = dlopen(libPath, {
init: { parameters: ['int32', 'pointer', 'string', 'pointer'], result: 'pointer' },
send_key: { parameters: ['uint8', 'int32'], result: 'void' },
get_framebuffer: { parameters: [], result: 'pointer' },
frame_ready: { parameters: [], result: 'int32' },
clear_frame_ready: { parameters: [], result: 'void' },
cleanup: { parameters: [], result: 'void' },
doomgeneric_Tick: { parameters: [], result: 'void' }
})
There’s no generated binding layer in the repository, and no native addon just to expose a few functions. The native library stays native, and the integration is done with plain JavaScript.
The main loop is intentionally simple, which is exactly what you want:
runtime.timer = setInterval(() => {
runtime.engine.tick()
if (!runtime.engine.frameReady()) {
return
}
runtime.renderer.render()
runtime.engine.clearFrameReady()
}, 1000 / 35)
Doom runs at 35 Hz. JavaScript calls tick(), checks if a frame is ready, renders it, and then clears the flag.
The frame path is pull-based. The C side doesn’t call back into JavaScript for every frame. Instead, JavaScript asks for work when it’s ready. This keeps the control flow simple and the JS/native boundary going in one direction for the main loop.
The FFI boundary
The best part about node:ffi isn’t just that JavaScript can call C. It’s that the boundary is visible in your code. You can see the native symbols, parameter types, and return types right where the library is loaded.
That’s powerful, but it’s not magic. FFI is low-level. If you mess up a pointer’s lifetime, pass the wrong type, or get a function signature wrong, you might crash instead of getting a friendly JavaScript error. That’s why Destino keeps the surface area small.
The performance side is getting interesting, too. In the Banter episode, we talked about early node:ffi calls taking around 150 nanoseconds. Recent work has brought that down to roughly 15 nanoseconds per call, close to the theoretical minimum for this kind of boundary.
Destino doesn’t need ultra-low call overhead to be a fun demo. A 35 Hz game loop isn’t high-frequency trading. Still, performance matters. It shows that FFI doesn’t have to be limited to rare setup calls. With a careful API, it can be used in real runtime paths.
Rendering Doom in a terminal
The Doom engine exposes a BGRA framebuffer. Destino maps that native memory into a JavaScript Buffer through node:ffi, scales it, and passes the result to OpenTUI.
The key thing is that Destino doesn’t copy the native framebuffer just to look at it. It borrows the native memory and reuses a separate scaled output buffer for rendering.
OpenTUI consumes a 2x2 supersample pixel grid per terminal cell, so Destino scales the frame into that shape while preserving Doom’s aspect ratio. It also handles the usual terminal details: alternate screen mode, hidden cursor, centring, margins, and cleanup.
There is also a Kitty graphics path. Destino uses it only when the terminal has fewer than 100 rows, and the terminal supports the Kitty graphics protocol, such as Kitty, Ghostty, or WezTerm. In that mode, Destino converts frames to RGBA, chunks them into protocol payloads, writes them to the terminal, and deletes the previous image after the new one is drawn.
Terminals aren’t game consoles. They have cell geometry, escape sequences, scrollback, inconsistent protocol support, and lots of odd behavior across emulators. Destino uses only what it needs and keeps the renderer code separate from the engine.
Input is the awkward part
Doom needs key press and release events, but terminals are much better at handling text than acting as game controllers.
Destino uses the Kitty keyboard protocol when available because it can report press, repeat, and release events. The input parser maps those terminal events to Doom key codes. Keybindings live in destino.json, so they are easy to change.
The defaults are what you would expect:
Action | Keys |
Move forward |
|
Move backward |
|
Turn left |
|
Turn right |
|
Strafe left | q, |
Strafe right |
|
Fire |
|
Use |
|
Menu |
|
Pause |
|
On first run, Destino writes destino.json in the current directory and exits. It tries to find freedoom1.wad and .sf2 files under the current directory, writes the paths it finds, and leaves you with a config file to review before starting the game.
It’s a small quality-of-life detail, but it matters. The first run shouldn’t feel like a scavenger hunt through command-line flags.
Audio stays native
Destino does not mix audio in JavaScript. DoomGeneric handles sound through SDL2 and SDL2_mixer. Node.js coordinates the process, but the native audio path does the audio work.
That split is what makes the project work well. JavaScript takes care of orchestration, configuration, input, rendering choices, packaging, and process lifecycle. The native code handles the engine and audio.
Neither side has to pretend to be something it’s not.
Packaging it
Destino can be packaged as a Node.js Single Executable Application (SEA) on macOS and Linux:
npm install
npm run dependencies
npm run build
npm run sea
The SEA build bundles the JavaScript entry point, native libraries, WAD files, and SF2 sound font into dist/destino. It also enables --experimental-ffi, so the result can run directly:
./dist/destino
For a demo like this, SEA takes care of a lot of the setup. The executable can include the JS bundle, Doom library, renderer, assets, and sound font all together.
There is one practical detail: the native libraries and game assets still need filesystem paths at runtime. Destino embeds them as SEA assets, then extracts them on startup into a per-process temporary directory such as destino-${process.pid}. The runtime uses node:sea’s getAssetKeys() and getRawAsset() APIs to enumerate the embedded assets, recreate their directory structure, and write them to disk before loading the Doom and OpenTUI libraries. On shutdown, it removes the temporary directory.
The extraction code is small, but it’s the kind of thing other SEA apps will probably need too. If more projects start bundling native libraries and assets inside SEA binaries, this could become a reusable helper instead of something everyone copies by hand.
Native packaging is still where platform details come into play. Shared library names, linker behavior, system packages, and asset paths all matter.
Trying it
Destino needs Node.js 26.1.0, the first Node.js release with node:ffi support.
You also need cmake, clang, pkg-config, unzip, SDL2_mixer development files, a Doom-compatible WAD such as Freedoom, an SF2 sound font such as GeneralUser GS, and a terminal with Kitty keyboard protocol support.
Automatic dependency setup is supported on macOS and Ubuntu Linux:
npm install
npm run dependencies
npm run build
Then run it with Node.js 26.1.0:
/path/to/node --experimental-ffi src/index.js
On the first run writes destino.json and exits. Check the generated paths, then run the same command again.
If your terminal has fewer than 100 rows and supports Kitty graphics, Destino uses the Kitty renderer. Otherwise, resize the terminal to at least 160 columns by 100 rows.
The enterprise point hiding in the joke
At first glance, Destino looks like a Doom stunt. And honestly, it is.
But there’s more to it: Destino changes the conversation for teams with native code they can’t easily replace.
Many companies have C libraries, shared objects, or DLLs that still do important work. They might calculate pricing, parse old formats, talk to hardware, run simulations, or hold domain knowledge no one wants to rewrite. Modernizing is usually tough: you either wrap it in a service, rewrite it over years, or freeze the whole system because one native part is too risky to change.
FFI gives those teams another option: keep the native library that works, wrap it with a Node.js app, and improve the runtime, deployment, observability, and integration—without pretending the old code has to vanish right away.
That doesn’t make the hard parts disappear. You still need to be careful with ABI, memory ownership, concurrency, failure modes, and versioning. FFI makes the boundary easier to set up, but it’s not something you can ignore.
Still, it’s hard to ignore: if a JavaScript loop can run a native Doom engine at 35 fps in a terminal, it can probably call the legacy C library your business relies on.
What comes next
Destino opens the door to more experiments. The same approach could work for llama.cpp, GPU libraries, local AI inference, native graphics, or any old code that’s useful but hard to reach from JavaScript. Maybe the next demo will use NVIDIA GPUs. Maybe it’ll be Prince of Persia.
The specific demo matters less than how the integration works: JavaScript runs the app, native code does the specialized work, and FFI keeps the boundary small.
Platformatic didn’t port Doom because it was practical. We did it because the technology made it possible. Sometimes, that’s all you need to show where the real work begins.





