obOB STUDIO
Networking

The AWS Backend — Lambda, DynamoDB, and API Gateway for Games

6 min read

I don't want to run a server

Let me be honest: I am not an infrastructure person. I don't want to SSH into a box at 2 AM because a Node process crashed. I don't want to think about auto-scaling groups or load balancers. I just want my games to have multiplayer and I want it to cost me approximately nothing when nobody's playing.

That's how I ended up on AWS serverless. API Gateway WebSocket + Lambda + DynamoDB. No servers. No uptime babysitting. Pay-per-invocation pricing. My monthly bill for the game backend has never exceeded $2.

The architecture

Here's how the pieces fit together:

Player's Browser
  ↕ WebSocket
AWS API Gateway (WebSocket API)
  → $connect    → Lambda (onConnect)
  → $disconnect → Lambda (onDisconnect)
  → $default    → Lambda (onMessage)
  ↕
DynamoDB (rooms table)

API Gateway WebSocket API manages the persistent WebSocket connections. When a player connects, API Gateway assigns them a connectionId and invokes the $connect route's Lambda. When they send a message, it goes to $default. When they disconnect (or their connection times out), $disconnect fires.

Lambda functions handle the game logic. There are only three: connect, disconnect, and message. They're written in Node.js, each under 100 lines.

DynamoDB stores room state. Each room is an item with a room code as the partition key, a list of connected player IDs, and a TTL timestamp for automatic cleanup.

The connect handler

Dead simple. When a player connects, I just log their connection ID. I don't create a room yet — that happens when they explicitly send a "createRoom" or "joinRoom" message.

// onConnect Lambda
export async function handler(event) {
  const connectionId = event.requestContext.connectionId;
  console.log("Connected:", connectionId);
  return { statusCode: 200, body: "Connected" };
}

That's the entire function. I considered storing connection IDs in a separate table, but it's unnecessary — I only care about connections in the context of rooms.

The message handler

This is where the real work happens. The onMessage Lambda receives every WebSocket message, parses it, and routes based on the action field:

export async function handler(event) {
  const connectionId = event.requestContext.connectionId;
  const body = JSON.parse(event.body);

  switch (body.action) {
    case "createRoom":
      return await createRoom(connectionId, body);
    case "joinRoom":
      return await joinRoom(connectionId, body);
    case "broadcast":
      return await broadcast(connectionId, body);
    default:
      return { statusCode: 400, body: "Unknown action" };
  }
}

createRoom generates a 4-character alphanumeric code, creates a DynamoDB item with that code as the key, adds the creator's connection ID as the first player, and sets a TTL of 2 hours:

async function createRoom(connectionId, body) {
  const roomCode = generateRoomCode(); // 4 random uppercase chars
  await dynamo.put({
    TableName: ROOMS_TABLE,
    Item: {
      roomCode,
      players: [{ connectionId, name: body.playerName }],
      ttl: Math.floor(Date.now() / 1000) + 7200, // 2 hours
    },
  });

  // Send the room code back to the creator
  await sendToConnection(connectionId, {
    type: "roomCreated",
    roomCode,
  });

  return { statusCode: 200, body: "Room created" };
}

joinRoom looks up the room by code, appends the new player to the players list, and notifies everyone in the room:

async function joinRoom(connectionId, body) {
  const room = await getRoom(body.roomCode);
  if (!room) {
    await sendToConnection(connectionId, { type: "error", msg: "Room not found" });
    return { statusCode: 404, body: "Not found" };
  }

  room.players.push({ connectionId, name: body.playerName });
  await updateRoomPlayers(body.roomCode, room.players);

  // Notify all players in the room
  for (const player of room.players) {
    await sendToConnection(player.connectionId, {
      type: "playerJoined",
      players: room.players.map(p => p.name),
    });
  }

  return { statusCode: 200, body: "Joined" };
}

broadcast is the workhorse. It relays a message from one player to all other players in the same room:

async function broadcast(senderConnectionId, body) {
  const room = await findRoomByConnection(senderConnectionId);
  if (!room) return { statusCode: 404, body: "Not in a room" };

  const message = {
    type: body.type,
    payload: body.payload,
    from: senderConnectionId,
  };

  const sends = room.players
    .filter(p => p.connectionId !== senderConnectionId)
    .map(p => sendToConnection(p.connectionId, message));

  await Promise.all(sends);
  return { statusCode: 200, body: "Broadcast sent" };
}

Notice: the backend doesn't care what type or payload contain. It's game-agnostic. Garden Gnomes sends gnome positions. Meccha Chameleon sends player transforms. Oliver's Racers sends car telemetry. The Lambda just relays JSON. This is why one backend serves all three games.

The disconnect handler

When a player disconnects, I need to remove them from their room and notify the remaining players:

export async function handler(event) {
  const connectionId = event.requestContext.connectionId;
  const room = await findRoomByConnection(connectionId);
  if (!room) return { statusCode: 200, body: "OK" };

  room.players = room.players.filter(p => p.connectionId !== connectionId);

  if (room.players.length === 0) {
    await deleteRoom(room.roomCode);
  } else {
    await updateRoomPlayers(room.roomCode, room.players);
    for (const player of room.players) {
      await sendToConnection(player.connectionId, {
        type: "playerLeft",
        players: room.players.map(p => p.name),
      });
    }
  }

  return { statusCode: 200, body: "Disconnected" };
}

If the last player leaves, the room is deleted. Otherwise, the remaining players get notified.

DynamoDB TTL: the cleanup crew

Rooms have a ttl attribute set to 2 hours from creation. DynamoDB automatically deletes expired items — no cron job, no scheduled Lambda, no cleanup script. If a game crashes and nobody disconnects cleanly, the room just evaporates on its own.

This is one of those DynamoDB features that saves you from writing a bunch of boring infrastructure code. I love it.

No authentication

Games are anonymous. Players pick a display name when they join — that's it. No sign-up, no OAuth, no tokens. For a casual browser game, authentication would be friction that kills the fun. You share a room code with your friend, they type it in, and you're playing together in 3 seconds.

The chat system on the site is different — that uses Google sign-in. But games? No walls.

What it costs

Lambda pricing: $0.20 per million invocations. DynamoDB on-demand: fractions of a cent per read/write. API Gateway WebSocket: $1 per million messages.

In practice, a 4-player Garden Gnomes session generates maybe 2,000 messages over 10 minutes. At $1 per million, that's $0.002. Two tenths of a cent. I could run thousands of simultaneous game sessions before the bill hits $10/month.

This is why serverless is perfect for indie game backends. You pay nothing when nobody's playing. And when someone is playing, the cost is negligible. No idle servers eating money. No reserved instances. Just pure pay-per-use.

ob

Written by Oliver

Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.