Features | Pricing | Documentation | Contact | Blog | About

Serverless Game Servers/Backends on Proxylity

By Lee Harding | August 25, 2026 | 15 min read

Regarding game networking, people are quick to say "serverless won't work for this. You need low latency and stateful sessions." Fair points, but not the whole story.

I'm going to walk through a concrete architecture for a multiplayer Asteroids-style game with players, ships, shots, asteroids, physics, collisions. This isn't a purely theoretical exercise, I'll be showing some actual code. It's incomplete, but shows the shape of the thing.

This architecture isn't going to run at 100fps or with with massive numbers of players. But it has qualities that are hard to beat for indie games: completely free when idle, scales up without intervention, and no server fleet to babysit.

The pitch

Pros:

Cons:

Two planes, two protocols

The design splits into a lobby plane and a game plane, each with its own Proxylity listener, protocol, and compute model.

Two-plane game architecture on AWS with Proxylity UDP Gateway

The lobby (matchmaking, presence, chat)

The Lobby Listener is a single Proxylity endpoint handling text-based UDP. Player events (join, available, chat, status updates) flow into a Step Functions EXPRESS state machine that handles request/response interactions -- check presence, update the lobby table, return leaderboards, relay chat.

When a matchmaking group commits, the API Handler invokes the Game Manager -- a STANDARD state machine that orchestrates the full match lifecycle:

  1. Creates a game record in the Game Table
  2. Assigns players and updates the Player Table
  3. Provisions (or selects) a game listener endpoint and its associated SQS queue
  4. Starts the Game Loop Lambda, passing the queue URL and player tokens
  5. Sets a wait task -- the state machine pauses until the game completes
  6. On completion: updates player stats (played, won, lost), records scores, cleans up

STANDARD state machines can run for up to a year, so the Game Manager comfortably outlives almost any match.

The game loop (simulation)

The Game Loop is a single Lambda invocation that runs for the duration of a match (up to 15 minutes). Lambda here could be changed-out for MicroVM instances for even longer run times and flexibility. The Lambda isn't invoked once per packet -- it's invoked once per game and runs a continuous loop internally.

The Lambda receives a game ID, SQS queue URL, and player tokens:

public static async Task<JsonObject> FunctionHandler(
    JsonObject request, 
    ILambdaContext context)
{
    var gameId = request["gameId"]?.GetValue<uint>();
    var queueUrl = request["sqsQueueUrl"]?.GetValue<string>();
    var player_tokens = request["players"]?.AsArray()...;

    // Game runs for 5 minutes (300 seconds)
    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(310));

    var valkey = await ConnectionMultiplexer.ConnectAsync(VALKEY_CONNECTION_STRING);
    var game_state = new GameState(gameId, ...);
    var world = new World(new Vector2(0, 0)); // Top-down, no gravity

    InitializeWorld(world, game_state, player_tokens);
    InitializeCollisionHandlers(world, game_state);

    // Two concurrent loops
    var input_loop = InputLoopAsync(input_queue, sqs, queueUrl, cts.Token);
    var game_loop = RunGameLoop(gameId, input_queue, game_state, world, valkey, cts.Token);

    await Task.WhenAny(game_loop, input_loop);
    // ... cleanup, record scores ...
}

Two concurrent loops run inside the single invocation:

Input loop -- polls SQS for player actions, parses the binary input format (28 bytes: game ID, player ID, button state, timestamp, HMAC signature), and enqueues them for the game loop.

Game loop -- runs at 60fps internally. Each frame:

  1. Applies queued player inputs (thrust, rotation, shoot)
  2. Limits player speed and shot count
  3. Steps the physics world (Aether Physics 2D)
  4. Handles collisions (player-asteroid, shot-player, shot-asteroid, etc.)
  5. Wraps coordinates (toroidal world)
  6. Checks win conditions (last player standing)
  7. Serializes game state to a fixed-size binary buffer
  8. Writes the buffer to ValKey

The game state is a compact binary frame with a header (8 bytes) + player records (26 bytes each) + asteroid records (12 bytes each) + shot records (18 bytes each). For 10 players, 20 asteroids, and 40 max shots, that's under the common MTU per frame.

The responder: how players get state

The Game State Responder is a separate Lambda attached to the Game Listener as a request/response destination. Using the Proxylity .NET SDK:

[assembly: LambdaSerializer(typeof(UdpGatewayLambdaJsonSerializer))]

public class Function
{
    static readonly Handler _handler = new();
    public static async Task<UdpGatewayBatchResponse> FunctionHandler(
        UdpGatewayBatchRequest request, ILambdaContext context)
    {
        return await UdpGatewayBatchProcessor.ProcessAsync(_handler, request, context);
    }
}

Every time a player sends a packet (their input), the Responder queries ValKey for the current game state buffer and replies with it. The player gets a fresh state snapshot with every action they send.

This means the effective update rate is player-driven. If client input is sent at 10 Hz, the game state gets 10 updates per second. The game loop is running at 60fps internally regardless, but clients get state at their own rate (or not at all if they drop off). For a casual action game, 10 Hz is responsive enough.

Input validation

Each input packet includes a 16-byte HMAC signature computed over the payload using the player's token (assigned at match start). The game loop validates every input:

private static bool ValidateSignature(byte[] token, InputRecord input)
{
    var hmac = new HMACSHA256(token);
    var computed = hmac.ComputeHash(input.payload);
    return computed.AsSpan(0, 16).SequenceEqual(input.signature);
}

Invalid inputs are silently dropped. Combined with server-authoritative physics (the game loop controls all movement, the client only sends button state), this prevents most forms of cheating (speed hacks, position spoofing, phantom shots).

Why ValKey instead of DynamoDB for game state

DynamoDB is great for lobby state (player profiles, game records, leaderboards) where you need durability and query flexibility. But for the game loop writing state at 60fps and the responder reading it on every player packet, you need sub-millisecond reads and writes. ValKey (ElastiCache) gives you single-digit microsecond access times. The entire game state is a single key (game_state:{gameId}) holding a binary blob under 2KB -- ValKey handles this trivially.

If we swapped-in MicroVMs things change, and we no longer need an external state store at all (the response Lambda could directly ask the MicroVM instance for state).

The fair queue (SQS magic)

The Game Listener routes player packets to an SQS queue with MessageGroupId per player. This is AWS's "fair queue" behavior where consumption is distributed fairly across message groups. A player spamming inputs at 100 Hz can't starve other players of processing time. SQS ensures each player's messages get equal pull priority.

SQS also provides backpressure for free. If the game/input loop falls behind, packets queue rather than causing Lambda throttles or dropped invocations.

Packet loss and disconnection

Networks drop packets. That's fine here because:

Cold starts

The game loop Lambda is invoked by the Game Manager when a match starts, not by player traffic. So cold starts happen exactly once per match, during the setup phase (while players are seeing a "match starting" screen). After that, the Lambda is warm for the entire 5-minute match duration.

The responder Lambda does face cold starts on the first player packet. With .NET NativeAOT (which the SAM template uses -- Runtime: provided.al2023 with a Makefile build), cold start is ~100-200ms. After the first packet, it stays warm for the match duration. Go and Rust are similar. Provisioned concurrency eliminates this entirely if needed.

Anti-abuse with WireGuard

A public UDP port is said to be an open invitation for abuse. The solution here is to run both listeners as Open WireGuard Listeners (AllowUnknownPeers: true). Players receive a WireGuard keypair at signup. The game client connects using their key.

Banning players

Add the banned player's public key as an explicit Peer with AllowedIPs set to an unroutable /32:

Peers:
  - PublicKey: "<banned player's public key>"
    AllowedIPs: ["198.51.100.0/32"]  # unroutable -- silently drops all traffic

Stack update completes in 1-2 seconds when listeners are in a dedicated stack. The WireGuard layer recognizes their key but since the external traffic can never match the CIDR, routes traffic nowhere. They can't spoof around it. Silent ban.

A game without a game loop?

The 60fps (or whatever) physics loop shown above is only necessary when your game has time-based server-side simulation where things move, collide, and expire whether or not players are pressing buttons. Asteroids needs it because asteroids drift, shots travel, and physics ticks regardless of input.

But many multiplayer games don't need continuous simulation at all:

The game loop Lambda is the most expensive component (a long-running invocation consuming memory for the match duration). If your game doesn't need continuous time progression, you can eliminate it entirely and make the architecture purely event-driven. As mentioned above, Lambda MicroVMs are another interesting option for running loops.

NAT keepalives

UDP is connectionless, but NAT gateways are not.

Most players are behind NAT (home routers, mobile carriers, CGNAT), and NAT mappings have a timeout. Typically, 30-60 seconds of silence result in the mapping being torn down. Once it's gone, the player can't receive packets anymore.

To ensure connectivity clients send packets at regular intervals even when the player isn't doing anything. A heartbeat every 10-15 seconds is sufficient. This is standard practice in UDP game clients, but worth noting because it interacts with the architecture in an interesting way.

Those keepalive packets are the mechanism by which the Responder sends state updates back to the player. Every inbound packet (input or heartbeat) gets a reply containing current game state. The NAT stays open, and the player stays synchronized. One packet, two problems solved.

PacketSource for server-push updates

Another different model eliminates the request/response pattern entirely. Instead of replying to each player packet with game state, the game loop (or game logic) pushes state to all players continuously via a PacketSource (SNS topic that Proxylity delivers as outbound UDP packets).

In this model:

  1. Clients send inputs (and keepalives) to the Game Listener -- fire-and-forget, no reply expected
  2. The Game Listener routes inputs to SQS as above
  3. The game logic publishes state updates to the PacketSource SNS topic
  4. Proxylity delivers those updates to all connected players as UDP packets

This is interesting because:

But NOTE: The NAT keepalive still matters in this model. Clients must keep sending packets to maintain the NAT mapping so that PacketSource deliveries can reach them. The difference is that those packets don't need to trigger any computation. The listener routes them to a the SQS destination and moves on, or simply absorbs them via ClientRestrictions + WireGuard. It's elegant.

PacketSource adds a small per-message SNS cost and requires managing the topic infrastructure, but the benefit of removing the Responder Lambda may make it worth it for your game.

Pick your model

Model Game loop State delivery Best for
Request/Response Continuous Lambda Responder replies to each player packet Physics-based action games
Event-driven + PacketSource Continuous Lambda Push to all players via SNS Strategy, tower defense, moderate-action
Pure request/response, no loop None (EXPRESS handles moves) Reply to each move directly Turn-based (chess, cards)

All three run on the same platform, use the same listeners, and share the same cost-at-idle story ($0). The difference is in how much continuous compute you need.

What this costs

At idle: $0. No packets, no invocations, no SQS messages.

During a match (10 players, 5 minutes):

A rough estimate: a single 5-minute match costs a fraction of a cent in compute. A thousand concurrent matches costs a few dollars. Compare that to a dedicated game server at $50-200/month whether anyone is playing or not.

Fit

Good fit:

Not a fit:

Is the world playing?

Deploy the same stacks across regions. Proxylity's anycast routing handles geographic distribution. Matches are region-local, all players in a match connect to the same regional listener. The matchmaking logic can enforce this (group players by region).

Ready to build your serverless game?

Get started with Proxylity UDP Gateway today. No upfront costs — pay only for what you use.

Buy with AWS Try the Examples Explore Documentation