TL;DR: I wanted to explore how far AWS Lambda Web Functions go with something stateful and long-running, so I put the engine of Doom, the 1993 first-person shooter, in a function. It’s the classic test for any new compute platform: can it run Doom?
The game runs on the server, compiled to WebAssembly, and the browser just shows frames. Requests land on any execution environment, so game state lives in Amazon DynamoDB. An environment that gets a request for a game it isn’t running rebuilds it by replaying the input log. A handoff between environments takes about 120 ms, and you don’t notice it while playing. You can try it at fps.julianwood.dev and deploy it from the repo.
A shooter on Lambda Web Functions?
Web Functions spread requests across execution environments with no session affinity. For a stateless API that doesn’t matter. What happens when each user has live state that changes 35 times a second, and a single connection stays open for minutes?
A game tests that harder than most apps. If the function loses track of your game, you see it straight away.
I picked doomgeneric, a version of the 1993 Doom engine built to be ported to new platforms, and Freedoom, the free, open game data that runs on it. The engine is GPL and Freedoom is BSD, so the whole thing can be shared.
Lambda Web Functions run Node.js. Doom is C.
Web Functions run Node.js 24 today, and Node runs WebAssembly natively. So I (OK, with the help of the AI robots) compiled the C engine to a 2.7 MB WebAssembly module with zig cc, targeting WASI, and loaded it with node:wasi. The function only ever runs JavaScript. The engine is a dependency it loads, like any .wasm package.
doomgeneric expects you to write a small platform file that gives it a screen, a keyboard and a clock. Mine has no window and no graphics library:
- Screen: Doom draws into its usual 8-bit 320x200 buffer. Node reads the pixels straight out of WebAssembly memory.
- Keyboard: Node pushes key events into Doom’s input queue.
- Clock: Doom normally reads the system clock. Here Node sets the time and advances the game exactly one tic (1/35 of a second) at a time.
Because the host owns the clock, the same inputs at the same tics always produce the same game, down to the pixel. Handoffs, crash recovery and spectators all depend on that.
In 1993 Doom took the whole PC. A 486DX2/66 spent most of each 28 ms tic drawing the frame, and managed close to 35 frames a second. A 486DX/33 dropped to around 17, and a 386 often reached single digits unless you shrank the view. In the function, each game uses about 0.15 ms of CPU per tic, around 0.5% of a vCPU, so one environment can run plenty of them.
Streaming frames to the browser
Web Functions don’t run WebSockets, so the server streams frames down one long HTTP response, 17.5 a second. Each key press goes up as a small POST.
The Web Functions docs suggest Server-Sent Events (SSE) for streaming. SSE is text only, so frames would need base64, which adds a third to their size. A plain streamed binary response suits video better.
Bandwidth was another consideration. Each frame only sends what changed since the last one, so standing still costs almost nothing. Turning changes every pixel, and the first version used about 890 KB/s per player. Sending every second frame while keeping the game at full speed brought that to about 500 KB/s. That’s 17.5 frames a second, about what a 486DX/33 managed in 1993. I tested zstd and brotli too. They’d save another 30-40%, but not every browser can decode them natively, so I stopped there.
Keeping game state in DynamoDB
On a server you run yourself, a game stays where you put it. A Web Function endpoint can have multiple execution environments, and requests are spread across them with no session affinity. Your stream can reconnect to a different environment from the one running your game. So can your key presses. An environment can also stop without warning.
I considered three options:
- Keep the game in one environment and hope requests find it. Requests are spread across environments, so in testing they alternated between the two.
- Save the full game state somewhere after every tic. Doom’s state is megabytes of WebAssembly memory. Writing that 35 times a second isn’t sensible.
- Save the inputs, not the state. Because the engine is deterministic, the inputs are enough to rebuild the game exactly.
I went with option 3. One DynamoDB table holds three things per game:
- A lease, saying which environment runs the game (the owner), with a heartbeat.
- An input log, the key presses the owner applied.
- An inbox, for key presses that reached an environment not running the game.
When your stream reaches an environment that isn’t running your game, that environment asks for it. The owner writes its last inputs and hands the game over in one DynamoDB transaction. The new owner replays the log and carries on from the same tic. Replay is quick, about 30 ms per minute of play, and in my tests the new owner’s first frame was pixel-identical to the old owner’s last.
If the owner dies without handing over, another environment takes the game once the heartbeat is 2.5 seconds old. You lose up to half a second of input.
The status bar under the game tells you when it happens: “Moved to another server. Caught up on 1 min 54 s of play in 60 ms.”
The repo has the exact table layout, timings and API if you want to build the same thing.
The status bar
Under the game, a status bar shows the frame rate, the bandwidth and which execution environment is serving you. It also shows the Region, the round trip from your browser to the function, and the CloudFront edge you came in through.

Spectators
The input log also made watching easy to add. The page gives you a watch link and a QR code. Anyone who opens it sees your game but can’t control it.
If the spectator’s stream lands on the environment running your game, it gets the same frames you do. If it lands on the other one, that environment builds its own copy of the game by replaying the log, then keeps following new inputs, about a second behind. The copy matched the player’s frames in every test.
Good to know when you build this
- Bundle the AWS SDK. Event-driven Lambda runtimes include it, and the Web Function runtime leaves it out, so
@aws-sdk/client-dynamodbgoes in the package. - CDK currently has L1 constructs for Web Functions. You wire up the role and code upload yourself. An L2 is coming.
- Custom domains go through CloudFront. To run it for other people I put Amazon CloudFront in front, with origin access control signing requests and the endpoint set to IAM auth, so nobody can bypass it. The CloudFront grant is a resource policy on the function, which a small script sets after the deploy.
Where this pattern fits
Multiplayer action games, where every player shares one fast game loop, fit a Lambda MicroVM per match.
Web Functions fit anything with per-user state that can be rebuilt from a log, and that covers more than games. A document editor or a workflow run can log what the user did and rebuild from it. This works because the app is deterministic, and that works on any platform. The state lives in the inputs, so any environment can pick it up.
If you want to try it, the repo deploys with one cdk deploy.
If you build something stateful on Web Functions, I’d like to hear how you kept state without session affinity.