Building Faceteroids: A Multiplayer Game Server on Lambda MicroVMs

serverlesslambdamicrovmsgamedevwebsocketAWS

I am absolutely loving AWS Lambda MicroVMs. This new compute shape opens up an entirely new world of things to build on serverless. I already built an ephemeral coding environment that I use every day. But this time I wanted something different: a multiplayer game server that provides state as long as I need it and disappears when I don’t.

So I built Faceteroids. One MicroVM per room. Real-time WebSocket gameplay. When a room goes quiet, the VM suspends and billing stops. When someone comes back, it resumes in under a second. Zero servers to manage.

If you watched the companion video, this is the hands-on version. If you didn’t, no worries — this post stands on its own.

The code is at github.com/singledigit/faceteroids. Clone it and follow along.

What We’re Building

Faceteroids architecture — control plane, frontend, and MicroVM data plane

Here’s how it breaks down. The architecture has three pieces:

  1. Control planeAWS Lambda functions behind Amazon API Gateway HTTP API, with Amazon DynamoDB for room state and Amazon Cognito for host authentication. Deployed with SAM.
  2. Frontend — Static assets served from Amazon S3 via Amazon CloudFront. Infrastructure deployed with SAM; built assets pushed with aws s3 cp.
  3. Game server — A Lambda MicroVM running a Node.js WebSocket server. Deployed through the Lambda MicroVMs service API (not SAM or CloudFormation).

The control plane handles orchestration: login, room creation, token minting. The browser then connects directly to the MicroVM over WebSocket for real-time gameplay. Each piece deploys differently because each piece IS fundamentally different.

Prerequisites

Before starting, you need:

Install the shared types package (everything else depends on it):

cd shared && npm install && npm run build && cd ..

Then install the game server dependencies (SAM handles the control-plane deps on its own during sam build):

cd gameserver && npm install && cd ..

Deploy the Control Plane

The SAM template defines everything the control plane needs: Lambda functions, API Gateway, DynamoDB tables, Cognito user pool, S3 buckets (for artifacts and the frontend), CloudFront distribution, and the IAM roles that MicroVM operations require.

export AWS_REGION=<your-region>  # Must support Lambda MicroVMs
sam build && sam deploy

Once complete, note the stack outputs. You need ArtifactBucketName, BuildRoleArn, and ApiUrl for the next steps.

STACK=faceteroids
BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK \
  --query "Stacks[0].Outputs[?OutputKey=='ArtifactBucketName'].OutputValue" --output text)
BUILD_ROLE=$(aws cloudformation describe-stacks --stack-name $STACK \
  --query "Stacks[0].Outputs[?OutputKey=='BuildRoleArn'].OutputValue" --output text)

Build the MicroVM Image

MicroVM Lifecycle — from Dockerfile to running, suspended, and terminated

The game server runs in a Lambda MicroVM. To get there, I need to create a MicroVM image. That starts with a Dockerfile.

Take a look at the Dockerfile. It’s intentionally minimal:

FROM public.ecr.aws/docker/library/node:20-bookworm-slim
WORKDIR /app
COPY bundle.mjs ./bundle.mjs
ENV NODE_ENV=production \
    GAME_PORT=8080 \
    HOOK_PORT=9000 \
    HOOKS_ENABLED=true
EXPOSE 8080 9000
CMD ["node", "bundle.mjs"]

The game server gets bundled with esbuild into a single file (bundle.mjs), so all the container needs is Node and that one file. Two ports: 8080 for gameplay WebSocket, 9000 for lifecycle hooks.

The build and deploy sequence has three steps. First, bundle the server and upload the artifact to S3. The Dockerfile must sit at the root of the zip (the service looks for it there):

npm run bundle --prefix gameserver                    # -> gameserver/dist/bundle.mjs
zip -j image.zip gameserver/Dockerfile gameserver/dist/bundle.mjs
aws s3 cp image.zip "s3://$BUCKET/microvm-images/faceteroids.zip"

Next, create the image. No Docker running locally. AWS builds it server-side:

ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
IMG="arn:aws:lambda:${AWS_REGION}:${ACCOUNT}:microvm-image:faceteroids"
BASE="arn:aws:lambda:${AWS_REGION}:aws:microvm-image:al2023-1"

aws lambda-microvms create-microvm-image \
  --name faceteroids \
  --base-image-arn "$BASE" \
  --build-role-arn "$BUILD_ROLE" \
  --code-artifact "{\"uri\":\"s3://$BUCKET/microvm-images/faceteroids.zip\"}" \
  --hooks "$(cat gameserver/image-runtime.json)" \
--resources '[{"minimumMemoryInMiB":2048}]' \
  --environment-variables '{"GAME_PORT":"8080","HOOK_PORT":"9000","HOOKS_ENABLED":"true","NODE_ENV":"production"}'

The --resources flag sets the memory baseline (2048 MiB = 2 GB / 1 vCPU — the default). Under load, the VM auto-scales up to 4x that baseline. The --hooks flag tells the platform which lifecycle hooks the application supports.

Finally, poll until the build succeeds, then activate the image:

VERSION=1.0
aws lambda-microvms get-microvm-image-version \
  --image-identifier "$IMG" --image-version "$VERSION" \
  --query state --output text   # repeat until SUCCESSFUL

aws lambda-microvms update-microvm-image-version \
  --image-identifier "$IMG" --image-version "$VERSION" --status ACTIVE

At this point, the image is ready. Any run-microvm call in your account can use it.

The Lifecycle Hooks

Every MicroVM application implements lifecycle hooks. Your app runs an HTTP server on port 9000, and the platform calls it at key moments in the VM’s life. You don’t call the platform; the platform calls you. Your code responds with a status code and that’s the entire contract.

Take a look at the hook handler from gameserver/src/hooks/server.ts:

const BASE = '/aws/lambda-microvms/runtime/v1';

switch (hook) {
  case '/ready':
    // 200 once game engine is loaded; 503 to retry.
    return game.isReady() ? ok(res) : busy(res);

  case '/validate':
    // Exercise hot paths so the platform can prefetch snapshot pages.
    game.validateMockTick();
    return ok(res);

  case '/run': {
    // Per-VM identity arrives here. Seed is a fresh CSPRNG draw (never baked).
    const envelope = parse<RunHookEnvelope>(body);
    const payload = envelope?.runHookPayload
      ? parse<RunHookPayload>(envelope.runHookPayload)
      : parse<RunHookPayload>(body);
    const mode = isGameMode(payload?.mode) ? payload.mode : 'coop';
    const run: RunState = {
      roomId: payload?.roomId ?? 'unknown',
      mode,
      seed: Rng.freshSeed(),
      hostSecret: payload?.hostSecret ?? '',
    };
    setRunState(run);
    game.applyRunState(run);
    return ok(res);
  }

  case '/suspend':
    return ok(res);

  case '/resume':
    game.resumed();
    return ok(res);

  case '/terminate':
    return ok(res);
}

The most important hook is /run. When a VM starts for a real session, the platform sends a POST with the room configuration (room ID, game mode, host secret). The handler seeds the RNG with a fresh crypto.randomBytes draw and unblocks the game loop. This is critical: every VM boots from the same snapshot, so if you seed randomness at boot time, every room gets the same “random” layout. The /run hook is where per-room uniqueness gets injected.

The /ready hook signals when the snapshot should be captured. /validate exercises hot code paths so the snapshot optimizer knows which pages to prefetch. /suspend and /terminate are our chance to clean up, though we don’t need to since game state lives in memory and the snapshot preserves it. /resume re-seeds the RNG so post-resume randomness stays fresh.

Running a Room

When a player clicks “Create Room” in the UI, a Lambda function in the control plane starts the MicroVM. Take a look at the SDK call from control-plane/src/lib/microvm.ts:

const res = await client.send(
  new RunMicrovmCommand({
    imageIdentifier: MICROVM_IMAGE_ARN,
    executionRoleArn: EXECUTION_ROLE_ARN,
    idlePolicy: {
      maxIdleDurationSeconds: 900,
      suspendedDurationSeconds: 1800,
      autoResumeEnabled: true,
    },
    maximumDurationInSeconds: ROOM_MAX_DURATION_SECONDS,
    runHookPayload: JSON.stringify({ roomId, mode, hostSecret }),
  }),
);

The idlePolicy is where the suspend/resume behavior lives. After 900 seconds (15 minutes) with no active connections, the VM auto-suspends. It stays suspended for up to 1800 seconds (30 minutes) before auto-terminating. autoResumeEnabled: true means the next connection attempt wakes the VM up automatically.

The response gives back an endpoint (a unique HTTPS host for this VM) and a microvmId. The control plane stores both in DynamoDB and returns them to the client.

Connecting Players: Auth and WebSocket

Auth flow — Browser to Lambda to MicroVM via scoped token

The browser can’t talk to the MicroVM without a token. But the browser also has no AWS credentials. A Lambda function in the control plane mints the token on the player’s behalf.

When a guest joins a room (a public endpoint, no login required), the control-plane Lambda calls CreateMicrovmAuthToken:

const res = await client.send(
  new CreateMicrovmAuthTokenCommand({
    microvmIdentifier: microvmId,
    expirationInMinutes: WS_TOKEN_TTL_MINUTES,
    allowedPorts: [{ port: GAME_PORT }],
  }),
);
const wsToken = res.authToken?.['X-aws-proxy-auth'];

The token is scoped to one VM, one port, with a short expiry. The Lambda returns it to the browser alongside the VM endpoint.

Now the browser has a token, but it can’t set custom HTTP headers on a WebSocket connection. That’s a limitation of the WebSocket spec, unchanged since 2011. The MicroVM proxy solves this with subprotocols:

const ws = new WebSocket(`wss://${endpoint}/play`, [
  'lambda-microvms',
  `lambda-microvms.authentication.${wsToken}`,
  `lambda-microvms.port.${port}`,
]);

The proxy reads the subprotocol list, extracts the token, validates it, routes to port 8080, and strips all metadata before the connection reaches the game server. The game server never sees the auth token. It gets a clean WebSocket connection. The authentication happened at the proxy layer, transparently.

Suspend, Resume, Terminate

The idle policy I set in the RunMicrovmCommand handles the entire lifecycle automatically:

  • 15 minutes idle (no active WebSocket connections) → VM auto-suspends. Compute billing stops immediately.
  • Player reconnects → VM auto-resumes in under a second (median ~770ms in testing). Game state is preserved: same positions, same scores, same everything.
  • 30 minutes suspended with no resume → VM auto-terminates. Room is gone.

The host can also manually suspend and resume from the admin panel, which calls SuspendMicrovmCommand and ResumeMicrovmCommand in the control plane.

What It Costs

I’m using the 2 GB / 1 vCPU default baseline for Faceteroids. Lambda MicroVMs bill per second. Faceteroids uses the 2 GB / 1 vCPU default baseline. Under load, the VM can burst to 4x that (8 GB / 4 vCPU). Same per-unit rate. You just pay for more resources during the seconds the burst is active.

The rates: ~$0.10/vCPU-hour, ~$0.013/GB-hour. Snapshot storage is $0.08/GB-month.

One room running for 20 minutes costs about 5 cents. Thirty rooms running 8 hours a day for a month comes to roughly $920. Suspended VMs cost only snapshot storage (pennies per month), not compute.

Wrapping Up

Lambda now has two primitives in the same family. Functions for stateless, event-driven work. MicroVMs for stateful, session-oriented work. Faceteroids uses both in the same application. Functions for orchestration, MicroVMs for gameplay.

The pattern generalizes beyond games: AI agent sandboxes, dev environments, notebook servers, CI runners, security scanners. Anything that needs a dedicated, isolated compute environment per session, with the ability to pause when idle and resume on demand.

Resources:

← Back to blog