Phone-as-Controller Games Need a Different Backend
Guido Zuidhof's Ludum Dare 49 entry, Full Tilt, took first place in the Innovation category by turning a smartphone into a motion controller for a browser game running on a laptop. The game uses the phone's gyroscope and accelerometer as input, with Cloudflare Workers and Durable Objects handling the communication layer between the two devices. Zuidhof built the entire backend in under 48 hours, using the DeviceMotion API on the phone and a series of small stateful "rooms" on the server side to relay sensor data.
You can play the game at ld49.pages.dev or scan a QR code from your laptop to pair your phone.
Why Not WebRTC?
Browser-to-browser communication without a middleman is possible via WebRTC DataChannels, but that approach brings its own complications. WebRTC connections can be unreliable across certain network setups—especially behind multiple NATs—and may still require a proxy server for problematic cases. For a 48-hour game jam, Zuidhof considered that out of scope.
The latency requirements for this game are forgiving but not trivial. A response time under 100ms is acceptable, though lower double digits are preferable. A server placed geographically close to the player can pass messages between the phone and laptop quickly enough. This is where edge computing becomes a practical necessity rather than a convenience.
Spin Up a Room, Get a Code
The architecture starts with a game room: a connection point that both the phone and laptop browsers can join over WebSockets. Durable Objects provide a natural fit here—they act as small, single-threaded "mini servers" that can run close to the user who first requested them, and multiple clients can connect to the same object simultaneously.
The flow works like this:
- The laptop browser sends a POST request to a Cloudflare Worker API to create a room.
- The server responds with a four-character room code that uniquely identifies the session.
- The user enters that code on their phone—or scans a QR code with
https://ld49.pages.dev?room=ABCD—to join the same room.
The room code uses a restricted character set to avoid common lookalike characters that trip up manual entry:
const DICTIONARY = "2345679ADEFGHJKLMNPQRSTUVWXYZ"; // 29 chars (no 0, O, I, 1, B, 8)
A four-character code yields roughly 700,000 unique room identifiers, and since sessions don't last forever, codes can be recycled after a day or so.
Room Code Coordination Has a Catch
In a Cloudflare Worker, you can create a Durable Object with an ID generated from a name or with a random unique ID. The naive approach would be to derive the Durable Object ID directly from the room code. That works until a code gets reused: a room created for a user in Mumbai will live in a data center near Mumbai. If that same code is assigned a week later to a user in Los Angeles, their game room gets revived in Mumbai, and latency suffers.
Instead, Zuidhof decouples the room code from the Durable Object location. Each game session gets a new Durable Object with a random ID, and a central "Room Hub" Durable Object maintains the mapping from four-character codes to those random IDs. The Room Hub exposes two endpoints: one to request a new room, one to look up room details by code.
The room request handler does the code generation:
export async function handleRoomRequest(ctx: Context<Env>) {
const now = Date.now();
const reqBody = await ctx.request.json();
// We make some attempts to find a room that is available..
const attempts = 5
let roomCode: string;
let roomStorageKey: string;
for (let i = 0; i < attempts; i++) {
roomCode = generateRoomCode();
roomStorageKey = ROOM_STATE_PREFIX + roomCode;
const room = await ctx.state.storage.get<RoomData>(roomStorageKey);
if (room === undefined) {
break;
} else if (now - room.createdAt > MAX_ROOM_AGE) {
await ctx.state.storage.delete(roomStorageKey);
break;
}
if (i === attempts-1) {
return ctx.throw("Couldn't find available room code :(");
}
}
const roomData: RoomData = {
roomCode: roomCode,
durableObjectId: reqBody.durableObjectId,
createdAt: now,
}
await ctx.state.storage.put<RoomData>(roomStorageKey, roomData);
ctx.response.body = {
room: roomData
};
ctx.response.status = HttpStatus.Created;
}
One subtlety worth noting: the game room Durable Object is created in the Cloudflare Worker that makes the request to the Room Hub, not inside the Room Hub itself. Since the Room Hub runs in a single data center, creating game rooms from that location could put them far from the end user. Creating them from the Worker that handles the initial request keeps the object geographically close to the player.
Looking up a room is straightforward—the endpoint returns room data or a 404 if the code isn't found:
export async function handleRoomLookup(ctx: Context<Env, {roomCode: string}>) {
const now = Date.now();
let roomStorageKey = ROOM_STATE_PREFIX + ctx.params.roomCode;
const roomData = await ctx.state.storage.get<RoomData>(roomStorageKey);
if (roomData === undefined) {
ctx.throw(404, "Room not found");
return;
}
if (now - roomData.createdAt > MAX_ROOM_AGE) {
// About time we cleaned it up.
await ctx.state.storage.delete(roomStorageKey);
ctx.response.status = HttpStatus.NotFound;
return;
}
ctx.response.body = {
room: roomData
};
}
The Game Room Object
The game room Durable Object itself is minimal: it forwards sensor readings from the phone to the laptop. Zuidhof modified the Durable Objects chat room example for this purpose, which saved considerable time during the jam.
Connections are assigned one of two roles: "host" (the laptop running the game) or "peer" (the phone controller). Messages from peers are forwarded to the host; messages from the host are broadcast to all peers. The implementation keeps two lists of connections—one per role—and loops over the appropriate list on each incoming message, with extra handling for disconnects.
The setup is singleplayer today, but the role-based message passing would extend naturally to multiplayer. Multiple friends could join the same room, each using their phone as a controller in, say, a browser-based kart racer. That wasn't feasible in a 48-hour window, however.
Cutting Scope on the Frontend
Zuidhof's original plan was a 3D plane-flight game with aerobatic tricks—fitting for the jam's "Unstable" theme—but time ran short. He fell back to a simple snake-like game built with Phaser and Svelte, both of which he had barely used before. The game speeds up over time, tracks a score, and ends when the player hits the screen edge.
The assets were similarly pragmatic: MS Paint art, sound effects from sfxr, and a soundtrack composed in Chrome's Music Lab Song Maker. The game was distributed with Cloudflare Pages.
The source—described by Zuidhof as "pretty hacky"—is on GitHub.
Where This Fits
Serverless Durable Objects won't suit every real-time game. WebSockets guarantee reliable, ordered delivery, but some game types only care about the latest state—a second-old update can be useless or even harmful. For those cases, a different transport would be necessary.
Still, for indie developers who want global reach without managing a fleet of game servers, the model is compelling. The jam entry demonstrates that a stateful, geographically distributed backend can be assembled in under two days—and then left running without ongoing server maintenance.



