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:
- Zero cost when nobody is playing. Can be literally $0/month at idle.
- Scales from 1 match at a time to 1000 without you touching anything.
- Built entirely on familiar AWS primitives like Lambda, SQS, Step Functions, DynamoDB, ElastiCache. We have decades of operational experience with them.
- Global deployment is just "deploy the same stack in more regions."
- No servers to patch, no fleets to manage, no capacity planning.
Cons:
- You're not getting high framerate, low input latency, server-authoritative physics to the client. Practical update rate is lower, and input latency is higher.
- It's a new pattern, unfamiliar to some. It takes a mental shift from "I have a game server process" to "I have a game loop Lambda."
- Could be seen as "risky" despite the deep track record of each individual AWS service. This composition isn't common (yet).
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.
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:
- Creates a game record in the Game Table
- Assigns players and updates the Player Table
- Provisions (or selects) a game listener endpoint and its associated SQS queue
- Starts the Game Loop Lambda, passing the queue URL and player tokens
- Sets a wait task -- the state machine pauses until the game completes
- 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:
- Applies queued player inputs (thrust, rotation, shoot)
- Limits player speed and shot count
- Steps the physics world (Aether Physics 2D)
- Handles collisions (player-asteroid, shot-player, shot-asteroid, etc.)
- Wraps coordinates (toroidal world)
- Checks win conditions (last player standing)
- Serializes game state to a fixed-size binary buffer
- 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:
- Missed inputs: the game loop doesn't need every input. It processes whatever arrives. If a thrust command gets lost, the player just thrusts for one fewer frame. The game state remains consistent.
- Missed state updates: the client always gets the current full state on the next reply. There's no delta encoding to fall out of sync on. A missed frame just means the client interpolates for one extra tick.
- Disconnection: if the input loop receives no messages for 30 seconds, it ends (preventing dead games). If one player drops, the game continues for remaining players. Reconnection is a matter of sending another packet, triggering the responder to reply with the current state.
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.
- Implicit authentication. Every packet is cryptographically tied to a player identity. No session tokens in your protocol.
- Encrypted transport for free. Player traffic is protected without custom crypto.
- No per-player listener config. Open mode accepts any valid WireGuard peer.
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:
- Turn-based (chess, cards, tactics): state only changes when a player acts. No game loop Lambda, no ValKey, no SQS queue. The lobby plane handles everything -- player sends a move, Step Functions EXPRESS validates it, updates DynamoDB, replies with the new board state. An entire chess game costs a fraction of a thousandth of a cent.
- Event-driven action games (puzzle games, incremental): state updates are triggered by player input, not by a clock. You can use the same input-validates-and-updates pattern as chess, just with more complex state. No continuous Lambda invocation running idle between inputs.
- Hybrid (strategy with real-time elements): maybe you tick the simulation forward only when input arrives, computing the elapsed time since the last input and fast-forwarding the simulation to "now." This gives you the appearance of continuous simulation without paying for a full-time idle loop.
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:
- Clients send inputs (and keepalives) to the Game Listener -- fire-and-forget, no reply expected
- The Game Listener routes inputs to SQS as above
- The game logic publishes state updates to the PacketSource SNS topic
- Proxylity delivers those updates to all connected players as UDP packets
This is interesting because:
- Input handling and state delivery are fully decoupled. The game logic publishes state whenever it's ready -- after processing a batch of inputs, on a timer, or purely event-driven.
- You don't need the Game State Responder Lambda at all. State delivery is a publish, not a per-player function invocation.
- For event-driven games (no continuous simulation), the game logic only fires when input arrives: process the input, compute new state, publish to all players. Between inputs, nothing runs. This is the chess model, but it scales up to moderate-action games too.
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):
- Lambda (game loop): 1 invocation x 300s x 1024MB = ~$0.005
- Lambda (responder): ~3000 invocations (10 players x ~5 Hz x 60s) = ~$0.002
- SQS: ~3000 messages = negligible
- ValKey Serverless: sub-cent for the traffic volume
- Proxylity: usage-based per packet
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:
- Moderate-action indie games (top-down shooters, platformers) at low to moderate client update Hz
- Lobby/matchmaking for any game type
- Casual multiplayer (party games, trivia, social)
- Turn-based games (chess, card games, tactics)
- Any game where "free until someone plays" is the right economic model
Not a fit:
- High tick-rate server-authoritative simulation (large-scale FPS, fighting games)
- Many players in a single physics simulation (a single Lambda can only do so much)
- Games requiring sub-20ms input to server response time
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).