| TL;DR: Running DOOM as a Temporal Workflow demonstrates how Durable Execution recovers application state from recorded inputs, provided the code processing them is deterministic. DOOM’s engine advances from player inputs delivered through Workflow Updates and recorded in Event History, so a new Worker can replay those inputs to rebuild the game after a crash. Continue-as-New carries a compact game snapshot into a fresh run to keep history bounded, while Reset lets you rewind to before a fatal mistake. The same Temporal primitives that keep this game running support recovery in payment pipelines and long-running AI agents. |
BEHOLD! Temporal can run DOOM.
This isn’t to be confused with the Replay badges that run DOOM, or with Temporal Tantrum, the DOOM II mod. This post is about running DOOM on the “Temporal operating system.”
Running DOOM is a rite of passage for every OS and hardware platform, and Temporal is no different. Of course, Temporal isn’t an operating system in the traditional sense. But it does offer a model of computation that is durable and reliable by default, and you can think of Temporal Workflows as the programs that run on this hypothetical OS. So DOOM should “just work” as a Temporal Workflow. Right?
It turns out DOOM and Temporal fit together unusually well, for a reason that isn’t obvious at first. Let me walk you through how it works, and by extension, how Temporal works too.
Durable Execution, one tic at a time#
Temporal makes code durable by recording what happens to it. A Workflow is ordinary code, and as it runs, Temporal records every input it receives and every result it gets back in an Event History. Temporal never saves the Workflow’s memory. If the Worker running it crashes, another Worker re-runs the same code against the same history and ends up in the same state. That only works because Workflow code has to be deterministic: given the same history, it makes the same decisions every time.
So to run DOOM on Temporal, you need to break the game into discrete steps whose inputs Temporal can record.
The tempting answer is to treat the game’s memory as the Workflow’s state and snapshot that. DOOM only needed 4 MiB of RAM, after all. The browser tab you are reading this on probably requires at least 10x that amount! But remember that the game engine updates its state at a brisk 35 times per second. That’s about 140 MiB of snapshots every second, over 8 GiB a minute! That’s... not efficient. And definitely going to cause headaches at any sort of scale.
But id Software actually solved this problem back in 1993. DOOM’s engine is entirely deterministic. It uses fixed-point math instead of floats, and its “random” numbers come from a fixed 256-entry table.
The game state advances in fixed “tics,” each requiring a single 8-byte input struct per player. With the same set of tic inputs, you’d get the same engine state.
Meet ticcmd_t, DOOM’s analog to the Workflow event:
typedef struct
{
char forwardmove; // *2048 for move
char sidemove; // *2048 for move
short angleturn; // <<16 for angle delta
short consistancy; // checks for net game
byte chatchar;
byte buttons;
} ticcmd_t;
This is actually how DOOM demos (.lmp files) work, including the gameplay demo you see after waiting at the title screen. A demo is a starting map plus a list of ticcmds, and the engine replays the entire game by just reading through them one by one.
In other words, DOOM was already event-sourced, and a DOOM demo file is a Workflow history by another name. To translate the two:
| DOOM | Temporal |
|---|---|
demo file (.lmp) |
Workflow History |
| demo playback | Workflow replay |
| demo desync | non-determinism error |
| "this demo needs v1.9" | Workflow versioning |
So “porting DOOM to Temporal” comes quite naturally. The game-specific data Temporal needs to record is the stream of ticcmds. Within each run, the game state lives in Workflow memory. Temporal can rebuild it by replaying the recorded inputs, the same waydoom -playdemo does. We only take a snapshot when handing the game off to a new run, which we’ll get to later.
If you’d like some inspiration for how to map other types of programs to Temporal Workflows, check out Temporal’s Validated Patterns library.
Temporal DOOM architecture#
Before diving into the specifics of how DOOM runs on Temporal, here’s the big picture of the temporal-doom project. This is the basic single-player setup. Multiplayer started with the same input path, then moved input coordination to the web server to reduce latency, as we’ll see below.
You play DOOM in your web browser, which runs the game engine compiled to WebAssembly (WASM). Your browser sends ticcmds to a small web server, which proxies them to Temporal Cloud. Each endpoint maps to a Temporal SDK call: for example, POST /api/games calls ExecuteWorkflow to start a new game.
A separate Worker process polls Temporal for work and runs the Workflows and Activities as needed.
The Worker is responsible for handling Updates and Queries. It also has a few additional features, such as an Activity that updates a leaderboard so you can see who’s been clearing levels the fastest.
Mapping DOOM to a Workflow#
The most interesting part is how to cleanly map all of this to a Temporal Workflow.
Updates carry input, Queries carry pixels#
Temporal offers several ways to send messages to a running Workflow. An Update is a durable write that returns a result, and a Query is a read-only request that isn’t recorded in history. DOOM uses each for exactly what it’s good at.
Here’s the core of the Workflow. The key detail is that aside from starting up the game engine's "virtual machine," we register Workflow Update and Workflow Query handlers.
func (w *Workflows) DoomGame(ctx workflow.Context, in GameInput) (Summary, error) {
vm, _ := w.Engine.NewVM(context.Background(), in.Config.options(in.Snapshot))
defer vm.Close()
...
workflow.SetUpdateHandlerWithOptions(
ctx, "tics", g.onTics,
workflow.UpdateHandlerOptions{Validator: g.validateTics})
workflow.SetQueryHandler(ctx, "frame", g.onFrameQuery)
...
// Wait until it's time to quit or roll over into a new run.
workflow.AwaitWithTimeout(ctx, idle, func() bool { return g.quit || g.shouldContinueAsNew() })
...
}
The "tics" Update handler calls the onTics function, which takes a batch of ticcmds from the player and advances the game engine's state. It returns a small stats struct with a checksum of the resulting game state.
This is one of the advantages of having a WASM-based version of the game engine running in the user's browser. Because the game engine is entirely deterministic, the Workflow Update doesn't need to return a lot of information about what things have changed. It only needs to acknowledge the inputs and return a checksum to confirm that the client and server are still in sync. (This gets a little more complicated for multiplayer.)
The "frame" Query renders the current game state as a PNG of what would appear on screen. This allows you to play DOOM without the game engine running in the browser via WASM, since you could simply read the display information by continuously querying the Workflow state. (There's some overhead for fetching the data, so this isn't really recommended if you want a fast-paced experience.)
Also, since Queries aren’t persisted, you can render as many frames as you like without growing the Workflow history.
The Workflow has no game loop or per-tic timers. The game only advances when new ticcmds arrive, so the client is the clock, and pausing is just the client not sending anything. (And the Temporal Workflow will be inert while idle.)
Anything that touches the outside world or is non-deterministic needs to run as an Activity. After the tics Update runs a batch of ticcmds through the engine, the Workflow compares the game’s state before and after. If the game just went from playing a level to the intermission screen, you finished the level, and the Workflow starts an Activity to post your kills and time to the leaderboard.
The Workflow doesn’t wait for that Activity to finish, because a slow write to the leaderboard shouldn’t stall the game. The Update returns right away, and the Workflow only waits for outstanding Activities just before the run ends or continues as new. If the leaderboard is down, Temporal retries the Activity, and the player never notices.
Fitting the engine into a Workflow#
The DOOM engine runs inside the Workflow, so it has to follow the same deterministic constraints as any other Workflow code. The engine is gore, a pure-Go port of DOOM. DOOM’s simulation is deterministic, but its main loop isn’t, because it reads the wall clock to decide how many tics to run, so a small refactoring was needed to create a “step mode” that never consults the system clock.
There was also a practical problem. gore keeps all of its game state in global variables, a leftover from the C it was translated from, which means one game per process. A Worker should be able to host many games at once, so the engine is compiled to WebAssembly and each game runs in its own instance using wazero, a pure-Go WebAssembly runtime.
This had other benefits, too. For example, it allows web browser clients to run the same simulation engine code that is running in the Temporal Worker, which becomes important for multiplayer. And the sandbox means the engine can’t read the real clock or touch the filesystem, even by accident.
Multiplayer#
Because the browser runs the same doom.wasm as the Worker, it can run the game itself instead of just displaying frames from a Query. In single player, the browser runs each tic locally and renders it immediately, without waiting on the Worker. When the Workflow acknowledges a batch, the browser compares the Workflow’s checksum with its own, and if they ever disagree, it resyncs by replaying the game’s inputs through a fresh engine. Two unrelated WebAssembly runtimes (wazero in the Worker and V8 in the browser) arrive at the same checksum, bit for bit.
Running the full game on every client is also the foundation for multiplayer, and it’s how DOOM’s original multiplayer worked over IPX, a mostly forgotten protocol for local area networks (LANs). Every machine ran the full simulation and exchanged only ticcmds, and a tic could only run once every player’s ticcmd for it had arrived. That’s lockstep netcode, and the ticcmd_t above even has a consistancy field (sic) so machines could notice when they diverged.
Single-player DOOM on Temporal is just lockstep with one player, so adding more players doesn’t change the design much. Something just needs to be the arbiter, and in the first version, that was the Workflow. It kept a queue of ticcmds for each player, and ran tic N as soon as every active player’s ticcmd for tic N had arrived. Each tics Update returned the merged tics (everyone’s ticcmds) that the caller hadn’t seen yet, at about 10 bytes per player per tic, and every browser ran those same tics through its own engine. Every copy of the game stays identical, and the checksums prove it.
This is the actor model: a single Workflow owns the game session, and messages (here, Updates) are the only way to change it. Temporal’s guide to building durable multiplayer game sessions walks through the same pattern in more depth. The only twist here is that there’s a 1993 game engine inside the actor.
The rest of the multiplayer features fall out of the same rules. Joining and leaving are part of a tic, so every engine spawns or removes a marine at exactly the same moment. When you pause, you’re marked as away, and the arbiter fills in empty ticcmds for you, so your marine stands still while everyone else plays on.
If a player stops sending without pausing (a dropped connection, or a rage quit after a well-aimed BFG blast), the arbiter marks them away after 3 seconds. That way one player can’t freeze the game for everyone.
This did require a small improvement over the original DOOM. Vanilla DOOM expected every player to be there when a level started, so the engine needed a few improvements to allow players to join and spawn in mid-level.
Alas, the first version was a little laggy#
The first version worked, and every player saw the same game. But it was noticeably laggy: there was a delay between pressing fire and seeing your shotgun go off.
The reason is lockstep itself. No client runs ahead of the arbiter, so you only see your own input after it has made the round trip there and back. With the Workflow as the arbiter, that round trip went from the browser, to the web server, to Temporal Cloud, to the Worker, and all the way back again… for each and every tic! Over a 1993 LAN, round trips took a few milliseconds and nobody noticed. Across the internet, they add up.
So in the second version, the small web server is the arbiter. Each browser streams its ticcmds to the web server over a WebSocket, and the server merges them and sends each tic back to every player as soon as it’s complete. About once a second, the server sends the batch of tics it has played to the Workflow as a single Update. Each player now only needs a quick round trip to the web server, and nothing in the game waits on Temporal.
The Workflow remains the source of truth. It referees every tic it records, runs them through its own copy of the engine, and returns a checksum so every browser can confirm it’s still in sync. If the web server crashes, the game picks up from the last batch the Workflow recorded.
Of course, there are plenty of ways to reduce the latency even further, or at least the perception of it. Client-side prediction of other players’ movements has been a staple of multiplayer games for the last 30 years. But that’s a project for another day.
You never need to save your game#
With DOOM running as a Workflow, you get some of Temporal’s features for free. Once a batch of inputs is recorded, Temporal can recover the game from it without a manual save. In the multiplayer version, the web server sends batches about once a second, so a web server crash can still roll back inputs that haven’t reached Temporal yet.
To see it, start a game, kill -9 the Worker in the middle of a fight, and start a new one. The new Worker has never seen the game. It replays the previously recorded history, and the game continues exactly where it left off. The player sees one relatively slow Update.
A single Workflow run can’t grow forever, though. Temporal keeps each run’s history bounded, so alas, a single run won’t be able to hold your weekend-long deathmatch. The answer is Continue-as-New, which closes the current run and starts a fresh one with new input. And DOOM already knows how to fit an entire game into a small input, because that’s literally what a savegame is!
Except rather than writing a file to disk, the Workflow uses DOOM’s own savegame code to take a snapshot of the engine’s state (about 25 KB) and passes it as the input to the next run. That run loads the snapshot and picks up right where the last one left off.
You don’t need to guess when to roll over. Temporal tracks each run’s history as it grows and tells the Workflow when it’s time to Continue-as-New. In Go, that’s a single call to workflow.GetInfo(ctx).GetContinueAsNewSuggested(), which the DOOM Workflow checks after every Update. Clients don’t need to know, because Updates are addressed to the Workflow ID rather than a specific run, so they always reach the newest one.
Undoing your death#
Because the whole game is a history of inputs, you can also go back in time.
Temporal’s Reset rewinds a Workflow to an earlier point in its history and resumes from there. In DOOM, that means you can resume the game from an earlier state, before you took an imp’s fireball to the face:
# Reset the Workflow with ID "doom-rewind-8669" back to event 530,
# which was before... mistakes were made.
$ temporal workflow reset \
--workflow-id doom-rewind-8669 \
--event-id 530 \
--reapply-exclude Update \
--reason "Rewind, and this time to AVOID fireballs"
The key flag is --reapply-exclude Update. Without it, Temporal re-applies all the later Updates, and you get the same game (and the same fireball) back. With it, you’re right back at the moment just before the one you’d rather forget.
The Temporal operating system, revisited#
We started by asking whether DOOM could run on the “Temporal operating system,” and the mapping holds up remarkably well. A game of DOOM is a Workflow, and its ticcmds are the Event History. Once the game is running that way, durability comes along for free: a crashed Worker costs you one slow Update, and a Reset can undo your death.
The mapping even extends to one of DOOM’s most familiar quirks. Everyone who played DOOM in the 90s has seen a demo desync. You play an old .lmp on a newer version of the game, and the marine confidently walks into a wall, because the new engine reacts differently to the same inputs. That’s the same danger Temporal’s determinism rules guard against: changing how code responds to recorded inputs. Temporal reports a nondeterminism error when replay produces a sequence of Commands that doesn’t match the recorded history.
DOOM’s answer was to pin the version (“you need DOOM v1.9 to watch this demo”), and Temporal DOOM does the same thing. Each game records a hash of the engine it started on, and a Worker running a different build won’t replay it. For changes to the Workflow’s own logic, Temporal’s versioning APIs let games already in flight keep the old behavior while new games get the new one. With pinned Workflows, Worker Versioning takes the idea further by routing each Workflow to Workers running its assigned deployment version.
Under the hood, DOOM and Temporal were built on the same idea: if the logic is deterministic, the inputs are enough to recover the state. id Software used it for demos and netcode in 1993, and Temporal uses it to make ordinary code durable. None of this needed special support from Temporal, either. The DOOM Workflow is built from the same pieces as any other Workflow, so the guarantees that let a 1993 shooter survive a kill -9 are the same ones that keep a payment pipeline or a long-running AI agent going when a machine dies.
So try it out! With the Temporal CLI, you can run temporal-doom locally. The code is at https://github.com/temporal-community/temporal-doom. It’s built using gore, wazero, and the Temporal Go SDK. The shareware doom1.wad is downloaded from Debian’s doom-wad-shareware package at build time.
