Most “cloud gaming” tutorials in 2026 are about streaming a console to a browser. This one is about something more boring and more useful: the invisible plumbing that keeps score. If you are building a game that needs a live leaderboard, real-time match updates, or a player-ranking system that does not fall over during a launch-day spike, you do not need a fleet of dedicated servers. You need a serverless backend built on AWS Lambda, Amazon API Gateway WebSocket APIs, and Amazon DynamoDB, provisioned with Terraform so it can be torn down and rebuilt in minutes.
This tutorial walks through building that exact system from scratch: a leaderboard API that accepts score submissions, ranks players with a DynamoDB global secondary index, and pushes live rank changes to connected clients over a persistent WebSocket connection. No game server to patch, no EC2 fleet to babysit, and a bill that scales down to near-zero when nobody is playing. By the end you will have a working project you can deploy to your own AWS account in under 90 minutes.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why a serverless leaderboard beats a dedicated server for most games
A traditional leaderboard service runs on a server that sits idle most of the day and gets hammered during peak hours. That mismatch is exactly what serverless architecture is designed to fix. AWS’s own Well-Architected Games Industry Lens documents a serverless-based game backend architecture built on Lambda, API Gateway, DynamoDB, SNS, and ElastiCache precisely because game traffic is bursty by nature: quiet for hours, then a spike the moment a streamer features your title or a weekend tournament starts. This pairs naturally with a real-time game analytics pipeline if you also want to track player behavior alongside scores, and with the broader cloud computing coverage on this site.
With Lambda, each function scales independently and you pay only for execution time, not idle capacity. With DynamoDB in on-demand mode, the table scales read and write throughput automatically. With API Gateway WebSocket APIs, a client opens one persistent connection and the backend pushes updates to it, rather than the client polling a REST endpoint every few seconds (which is what a surprising number of shipped games still do, and it is both slower and more expensive at scale).
The tradeoff: this stack is not meant to replace an authoritative, tick-based game server for a twitch shooter. It is built for leaderboards, turn-based state, lobby and matchmaking metadata, and lightweight real-time features — the categories of game-backend traffic that do not need sub-20ms server authority. If your game needs that, pair this leaderboard with a dedicated server layer; this guide focuses purely on the leaderboard and live-ranking path.
How this compares to GameLift, Agones, and Nakama
If you have already looked at Amazon GameLift, Agones on Kubernetes, or Nakama for your backend, it is worth being clear about where a serverless leaderboard fits relative to those options rather than competing with them directly. GameLift and Agones both manage fleets of long-running game server processes, the kind of infrastructure a real-time shooter or battle royale title needs for authoritative tick-based simulation. Nakama is a self-hosted or managed game server framework that bundles matchmaking, chat, and storage into one runtime you operate yourself. None of the three is built primarily around a sorted, globally queryable ranking table pushed to clients over a managed WebSocket layer, which is the specific problem this tutorial solves.
| Approach | Best for | Ops burden | Idle cost |
|---|---|---|---|
| Serverless leaderboard (this tutorial) | Leaderboards, rankings, turn-based state, lobby metadata | Low — no servers to patch | Near zero |
| Amazon GameLift | Authoritative real-time match sessions | Medium — fleet and build management | Depends on fleet sizing |
| Agones on Kubernetes | Self-managed dedicated game servers at scale | High — you own the cluster | Cluster runs continuously |
| Nakama | Full game backend framework, self-hosted or managed | Medium — runtime to operate or a hosted plan to pay for | Runs continuously unless scaled to zero manually |
In practice, a lot of shipped multiplayer games run more than one of these at once: GameLift or Agones for the match itself, and a serverless leaderboard layer like the one built in this tutorial sitting next to it for rankings, profile data, and the kind of lightweight real-time updates that do not need an authoritative tick-rate server. Treat this guide as the leaderboard layer of a larger architecture, not a wholesale replacement for a dedicated game server when your game genuinely needs one.
Prerequisites and versions used in this tutorial
You do not need a game engine to follow along since this is a backend tutorial, and the sample client is a plain HTML page with a few lines of JavaScript. You do need an AWS account and a terminal. Here is exactly what was used to build and test this project:
| Tool | Version used | Install command |
|---|---|---|
| AWS CLI | v2 (latest) | curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg" |
| Terraform | 1.9.x or newer | brew install terraform or official binary |
| Node.js | 20.x LTS (Lambda runtime nodejs20.x) | nvm install 20 |
| AWS account | IAM user with programmatic access | console.aws.amazon.com |
| wscat (optional, for testing) | latest via npm | npm install -g wscat |
Budget 60 to 90 minutes for the full build, including Terraform apply time. DynamoDB tables and API Gateway deployments typically provision in under two minutes, so most of that time goes into writing and testing the Lambda code, not waiting on AWS. You will need an AWS account with billing enabled; the free tier covers this entire tutorial for testing purposes, and ongoing cost at low traffic runs a few cents a month.
Step 1: Design the data model before you write any code
The single most common mistake in leaderboard projects is designing the DynamoDB table after the Lambda functions are already written. Do it the other way around. A leaderboard has two access patterns that matter: write a score, and read the top N scores sorted descending. DynamoDB does not sort by a numeric attribute natively unless you build a global secondary index (GSI) around it, so the table design needs to bake that in from the start.
The table in this tutorial uses a single-table design with three logical item types: player scores, WebSocket connection records, and leaderboard metadata. Here is the key schema:
| Attribute | Type | Role |
|---|---|---|
| PK | String | Partition key, e.g. LEADERBOARD#global |
| SK | String | Sort key, e.g. PLAYER#alice123 |
| score | Number | Current best score |
| gsi1pk | String | GSI partition key, same value as PK for ranking queries |
| gsi1sk | Number | GSI sort key, set to score for descending rank order |
| updatedAt | Number | Epoch timestamp of the last score update |
The GSI (named LeaderboardRankIndex in this project) is what makes “get the top 100 players” a single, cheap, sorted query instead of a full table scan. The AWS DynamoDB global secondary index documentation explains that a GSI lets you query on a different key than the table’s primary key while DynamoDB maintains it automatically as items are written, which is exactly the pattern a leaderboard needs.
Step 2: Initialize the Terraform project structure
Create a project directory and lay out Terraform files by resource type rather than dumping everything into one file. This keeps the project navigable once you add more Lambda functions later.
mkdir serverless-leaderboard && cd serverless-leaderboard
mkdir -p terraform lambda/submit-score lambda/get-leaderboard lambda/ws-connect lambda/ws-disconnect lambda/ws-broadcast
touch terraform/main.tf terraform/variables.tf terraform/outputs.tf \
terraform/dynamodb.tf terraform/lambda.tf terraform/apigateway.tf
terraform -chdir=terraform init
In terraform/main.tf, declare the AWS provider and a remote backend if you already use one for other projects, typically an S3 bucket with DynamoDB state locking, as shown in most Terraform AWS provider documentation examples. If you are new to Terraform on AWS, the Terraform on AWS setup tutorial on this site covers provider configuration and state management in more depth. For a single-developer test project, a local state file is fine to start.
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Step 3: Provision the DynamoDB table with Terraform
Add the table definition in terraform/dynamodb.tf. Use PAY_PER_REQUEST billing mode so you are not pre-provisioning read and write capacity units for traffic you cannot predict yet. This is the single biggest cost-control decision in this whole project, and switching to provisioned capacity later, once your traffic is predictable, is a one-line change.
resource "aws_dynamodb_table" "leaderboard" {
name = "leaderboard-table"
billing_mode = "PAY_PER_REQUEST"
hash_key = "PK"
range_key = "SK"
attribute {
name = "PK"
type = "S"
}
attribute {
name = "SK"
type = "S"
}
attribute {
name = "gsi1pk"
type = "S"
}
attribute {
name = "gsi1sk"
type = "N"
}
global_secondary_index {
name = "LeaderboardRankIndex"
hash_key = "gsi1pk"
range_key = "gsi1sk"
projection_type = "ALL"
}
point_in_time_recovery {
enabled = true
}
tags = {
Project = "serverless-leaderboard"
}
}
Run terraform -chdir=terraform plan to confirm the table resource looks correct before applying anything else. Point-in-time recovery costs a small percentage on top of storage and is worth enabling from day one. Restoring a corrupted leaderboard table manually, after the fact, is a miserable way to spend an afternoon.
Step 4: Write the score-submission Lambda function
This function receives a player ID and a score, validates it, and writes it to DynamoDB only if it beats the player’s existing best, a common leaderboard rule that avoids a sloppy score overwriting a better one submitted earlier in the session.
// lambda/submit-score/index.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
export const handler = async (event) => {
const body = JSON.parse(event.body || "{}");
const { playerId, score, board = "global" } = body;
if (!playerId || typeof score !== "number" || score < 0) {
return { statusCode: 400, body: JSON.stringify({ error: "invalid playerId or score" }) };
}
const pk = `LEADERBOARD#${board}`;
const sk = `PLAYER#${playerId}`;
// Conditional update: only write if score is higher than current best
await client.send(new UpdateCommand({
TableName: TABLE,
Key: { PK: pk, SK: sk },
UpdateExpression: "SET score = :s, gsi1pk = :pk, gsi1sk = :s, updatedAt = :t",
ConditionExpression: "attribute_not_exists(score) OR score < :s",
ExpressionAttributeValues: {
":s": score,
":pk": pk,
":t": Date.now(),
},
})).catch((err) => {
if (err.name !== "ConditionalCheckFailedException") throw err;
});
return { statusCode: 200, body: JSON.stringify({ accepted: true }) };
};
The ConditionExpression is doing the real work here. Without it, a late-arriving network request with a lower score could overwrite a legitimate high score, a race condition that is easy to miss in testing, where requests arrive in order, and painful to debug in production, where they do not.
Step 5: Write the leaderboard-query Lambda function
This function queries the GSI for the top N players, sorted descending. DynamoDB GSIs sort ascending by default, so the query needs ScanIndexForward: false to reverse that.
// lambda/get-leaderboard/index.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
export const handler = async (event) => {
const board = event.queryStringParameters?.board || "global";
const limit = Number(event.queryStringParameters?.limit || 20);
const result = await client.send(new QueryCommand({
TableName: TABLE,
IndexName: "LeaderboardRankIndex",
KeyConditionExpression: "gsi1pk = :pk",
ExpressionAttributeValues: { ":pk": `LEADERBOARD#${board}` },
ScanIndexForward: false,
Limit: limit,
}));
const ranked = result.Items.map((item, i) => ({
rank: i + 1,
playerId: item.SK.replace("PLAYER#", ""),
score: item.score,
}));
return { statusCode: 200, body: JSON.stringify({ board, leaderboard: ranked }) };
};
Step 6: Deploy the Lambda functions with Terraform
Each function needs an IAM role scoped to exactly what it touches: DynamoDB read and write on this one table, nothing broader. In terraform/lambda.tf:
resource "aws_iam_role" "lambda_exec" {
name = "leaderboard-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "dynamo_access" {
role = aws_iam_role.lambda_exec.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Query", "dynamodb:GetItem"]
Resource = [aws_dynamodb_table.leaderboard.arn, "${aws_dynamodb_table.leaderboard.arn}/index/*"]
}]
})
}
resource "aws_lambda_function" "submit_score" {
function_name = "leaderboard-submit-score"
role = aws_iam_role.lambda_exec.arn
handler = "index.handler"
runtime = "nodejs20.x"
filename = "../lambda/submit-score/function.zip"
timeout = 5
environment {
variables = { TABLE_NAME = aws_dynamodb_table.leaderboard.name }
}
}
Repeat the aws_lambda_function block for get-leaderboard, ws-connect, ws-disconnect, and ws-broadcast, pointing each at its own zipped source. Package each function with cd lambda/submit-score && npm install --production && zip -r function.zip . before running terraform apply.
Step 7: Set up the API Gateway WebSocket API
This is the piece most tutorials skip over, and it is the part that turns a static leaderboard into a live one. The AWS API Gateway WebSocket API documentation describes how a WebSocket API maintains a persistent, full-duplex connection between client and backend, routing messages to different backend integrations based on message content rather than requiring a new HTTP request for every exchange.
Every WebSocket API needs three special routes: $connect, $disconnect, and a catch-all or custom route for messages. Define them in terraform/apigateway.tf:
resource "aws_apigatewayv2_api" "ws" {
name = "leaderboard-ws-api"
protocol_type = "WEBSOCKET"
route_selection_expression = "$request.body.action"
}
resource "aws_apigatewayv2_route" "connect" {
api_id = aws_apigatewayv2_api.ws.id
route_key = "$connect"
target = "integrations/${aws_apigatewayv2_integration.connect.id}"
}
resource "aws_apigatewayv2_route" "disconnect" {
api_id = aws_apigatewayv2_api.ws.id
route_key = "$disconnect"
target = "integrations/${aws_apigatewayv2_integration.disconnect.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
api_id = aws_apigatewayv2_api.ws.id
name = "prod"
auto_deploy = true
}
The $connect route fires when a client opens a WebSocket connection. Its Lambda function, ws-connect, should store the connection ID in DynamoDB so the broadcast function can find it later:
// lambda/ws-connect/index.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const handler = async (event) => {
const connectionId = event.requestContext.connectionId;
await client.send(new PutCommand({
TableName: process.env.TABLE_NAME,
Item: {
PK: "CONNECTION#global",
SK: `CONN#${connectionId}`,
connectionId,
ttl: Math.floor(Date.now() / 1000) + 7200,
},
}));
return { statusCode: 200, body: "connected" };
};
Note the ttl field. If a client disconnects without triggering $disconnect cleanly, say a closed laptop lid or dropped Wi-Fi, a stale connection record left behind will cause a GoneException the next time the broadcast function tries to post to it. Setting a TTL on the item and enabling DynamoDB’s TTL feature on that attribute means dead connections quietly expire instead of accumulating forever.
Step 8: Broadcast live rank changes to connected clients
After a score is accepted in the submit-score function, trigger a broadcast function that scans the connections table and pushes the updated top-N list to every open socket using the API Gateway Management API.
// lambda/ws-broadcast/index.mjs
import { ApiGatewayManagementApiClient, PostToConnectionCommand } from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const apiClient = new ApiGatewayManagementApiClient({ endpoint: process.env.WS_ENDPOINT });
export const handler = async (event) => {
const payload = JSON.stringify(event.detail); // top-N leaderboard snapshot
const conns = await ddb.send(new QueryCommand({
TableName: process.env.TABLE_NAME,
KeyConditionExpression: "PK = :pk",
ExpressionAttributeValues: { ":pk": "CONNECTION#global" },
}));
await Promise.allSettled(conns.Items.map((c) =>
apiClient.send(new PostToConnectionCommand({
ConnectionId: c.connectionId,
Data: Buffer.from(payload),
})).catch((err) => {
if (err.name !== "GoneException") throw err;
})
));
return { statusCode: 200 };
};
The GoneException catch is not optional. Without it, one stale connection throws an unhandled error that can abort the whole broadcast before the remaining, still-connected clients get their update. Catching it and moving on is the difference between one client missing an update and every client missing an update.
Step 9: Wire up the HTTP API for score submission and queries
While the WebSocket API handles live push updates, score submission and one-off leaderboard reads are cleaner over a standard HTTP API, which is cheaper per request and easier to cache at the CDN layer than WebSocket traffic. Add an aws_apigatewayv2_api resource with protocol_type = "HTTP" alongside the WebSocket one, with two routes: POST /scores wired to submit_score, and GET /leaderboard wired to get_leaderboard.
resource "aws_apigatewayv2_api" "http" {
name = "leaderboard-http-api"
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "submit_score" {
api_id = aws_apigatewayv2_api.http.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.submit_score.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "post_scores" {
api_id = aws_apigatewayv2_api.http.id
route_key = "POST /scores"
target = "integrations/${aws_apigatewayv2_integration.submit_score.id}"
}
resource "aws_apigatewayv2_stage" "http_prod" {
api_id = aws_apigatewayv2_api.http.id
name = "prod"
auto_deploy = true
}
Step 10: Apply the Terraform configuration and deploy
With every resource defined, package the Lambda functions and run the full apply:
for fn in submit-score get-leaderboard ws-connect ws-disconnect ws-broadcast; do
cd lambda/$fn
npm install --production
zip -r function.zip . -x "*.git*"
cd ../..
done
terraform -chdir=terraform plan -out=tfplan
terraform -chdir=terraform apply tfplan
Terraform prints the HTTP API invoke URL and the WebSocket API endpoint as outputs once the apply finishes. A clean apply for this exact stack typically takes 60 to 120 seconds. DynamoDB tables and Lambda functions provision fast; API Gateway stages are slightly slower.
Step 11: Test the leaderboard end to end
Submit a test score over HTTP, then confirm it shows up in a leaderboard query:
# Submit a score
curl -X POST https://YOUR_HTTP_API_ID.execute-api.us-east-1.amazonaws.com/prod/scores \
-H "Content-Type: application/json" \
-d '{"playerId": "alice123", "score": 9850}'
# Query the top 10
curl "https://YOUR_HTTP_API_ID.execute-api.us-east-1.amazonaws.com/prod/leaderboard?limit=10"
Expected output example:
{
"board": "global",
"leaderboard": [
{ "rank": 1, "playerId": "alice123", "score": 9850 },
{ "rank": 2, "playerId": "bob456", "score": 8720 },
{ "rank": 3, "playerId": "carla789", "score": 8010 }
]
}
Then test the live push path by connecting with wscat in one terminal and submitting a score from another:
wscat -c wss://YOUR_WS_API_ID.execute-api.us-east-1.amazonaws.com/prod
If the broadcast Lambda is wired correctly, submitting a new high score in the other terminal pushes a JSON payload into the open wscat session within a second or two, with no polling involved on the client side.
Step 12: Add CloudWatch monitoring for the leaderboard pipeline
A serverless stack hides its moving parts behind managed services, which makes monitoring more important, not less. Without visibility into Lambda errors, DynamoDB throttling, and WebSocket connection counts, the first sign of trouble is a player complaint rather than an alert. Add a CloudWatch dashboard and a handful of alarms before you consider this project production-ready.
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
alarm_name = "leaderboard-submit-score-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 60
statistic = "Sum"
threshold = 5
dimensions = {
FunctionName = aws_lambda_function.submit_score.function_name
}
alarm_actions = [aws_sns_topic.alerts.arn]
}
resource "aws_cloudwatch_metric_alarm" "dynamo_throttle" {
alarm_name = "leaderboard-table-throttled-requests"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ThrottledRequests"
namespace = "AWS/DynamoDB"
period = 60
statistic = "Sum"
threshold = 0
dimensions = {
TableName = aws_dynamodb_table.leaderboard.name
}
alarm_actions = [aws_sns_topic.alerts.arn]
}
Three metrics matter most in this architecture: Lambda error count and duration (a sudden jump in duration on the broadcast function usually means the connections table has grown large enough to need the EventBridge fan-out pattern described in the advanced tips below), DynamoDB throttled requests (which should be zero on on-demand billing mode, and a non-zero value usually signals a hot partition key rather than a capacity problem), and the WebSocket API’s ConnectCount and MessageCount metrics, which tell you whether broadcast volume is tracking player activity the way you expect.
Route the aws_sns_topic.alerts topic to an email subscription or a Slack webhook through Chatbot so an alarm actually reaches someone instead of sitting unread in the CloudWatch console. A leaderboard that silently stops accepting scores for six hours because of a bad deploy is a worse outcome than a false-positive alert at 2 a.m.
Step 13: Add friends-only and time-windowed leaderboard variants
Most real games need more than one leaderboard: a global all-time board, a weekly or seasonal board, and sometimes a friends-only board. The single-table design from Step 1 already supports this since each board is just a different PK value, such as LEADERBOARD#global, LEADERBOARD#weekly-2026-40, or LEADERBOARD#friends-alice123. To add a weekly board, compute the ISO week number client-side or in the Lambda function and pass it as the board parameter; no schema change is required.
For a time-windowed board that should reset automatically, set a DynamoDB TTL on the weekly items so old boards clean themselves up rather than accumulating stale data that nobody queries again.
Step 14: Add rate limiting and basic anti-cheat validation
A publicly reachable score-submission endpoint is an open invitation for a player to just curl a fake world record into your leaderboard. Two concrete mitigations belong in this project, not a future-work list:
- Usage plans and API keys, or a WAF rate-based rule, on the HTTP API to cap how many score submissions a single client can make per minute.
- Server-side plausibility checks in the submit-score Lambda, rejecting scores above a hard ceiling your game design knows is mathematically impossible, and logging anything close to that ceiling for manual review instead of silently accepting it.
Neither of these is a complete anti-cheat system, but both are cheap, and skipping them is how a leaderboard launch turns into a leaderboard full of nonsense scores within 48 hours.
Cost breakdown: what this actually costs to run
One advantage of this architecture over a dedicated server is that the idle cost sits close to zero. Here is a rough estimate based on published 2026 pricing for the core services:
| Service | Pricing model | Approx. cost at 100K score submissions/month |
|---|---|---|
| AWS Lambda | Per request plus GB-second, per AWS Lambda pricing | Under $1/mo at this volume, often within free tier |
| DynamoDB on-demand | Per read/write request unit, per DynamoDB on-demand pricing | A few dollars/mo depending on item size and GSI writes |
| API Gateway HTTP API | Per million requests | Well under $1/mo at 100K requests |
| API Gateway WebSocket API | Per message plus per connection-minute | Depends heavily on concurrent connections and broadcast frequency |
The one line item to watch is WebSocket connection-minutes. If your game keeps thousands of idle sockets open for hours, that adds up faster than the per-message cost does. For a casual mobile game where players check the leaderboard a few times a session rather than staying connected continuously, this stays cheap. For a game that keeps every player connected live for a 30-minute match, model that traffic before committing to this architecture at scale.
Common pitfalls when building a serverless game leaderboard
- Scanning instead of querying. Calling
Scanon the leaderboard table instead ofQueryon the GSI works fine with 50 test players and falls over the moment you have 50,000, because scans read every item and get slower and more expensive as the table grows. - No TTL on connection records. Dead WebSocket connections pile up in DynamoDB forever if you never clean them up, and every broadcast wastes a call trying, and failing, to reach them.
- Treating API Gateway WebSocket like a raw TCP socket. It is message-based and stateless between invocations; you cannot hold state in Lambda memory between messages the way you could with a long-running server process. All state has to live in DynamoDB.
- Skipping the conditional write on score updates. Without it, out-of-order requests can silently downgrade a player’s recorded best score.
- Using provisioned DynamoDB capacity before you know your traffic shape. On-demand mode costs more per request but removes the risk of throttling during a launch spike, which is the worse outcome for a new game.
- Forgetting IAM least-privilege. Giving every Lambda function full
dynamodb:*access instead of scoping to the specific table and actions it needs is a common and avoidable security gap. - Not handling
GoneExceptionin the broadcast function. One stale connection ID can abort an entire broadcast loop if the error is not caught per connection. - Hardcoding the WebSocket management API endpoint. It changes per API ID and stage; pull it from Terraform outputs or environment variables, not a hardcoded string.
Troubleshooting guide
- WebSocket connects then immediately disconnects: check that the
$connectLambda returns a 200 status code. Any non-200 response causes API Gateway to reject the connection. - Score submissions silently do nothing: check CloudWatch Logs for the submit-score function for a
ConditionalCheckFailedException. This is expected behavior when a lower score is submitted, not a bug, but it is easy to mistake for a silent failure. - Leaderboard query returns an empty list: verify the GSI has finished backfilling after table creation. A newly created GSI on an existing table can take time to populate, though on a fresh table this is near-instant.
- Broadcast function throws
AccessDeniedException: the Lambda execution role needsexecute-api:ManageConnectionspermission on the WebSocket API ARN, separate from the DynamoDB permissions. - Terraform apply fails with a duplicate route key error: you likely defined two routes with the same
route_keyon the same API, and API Gateway requires uniqueness per API. - Clients receive duplicate broadcast messages: check whether the submit-score function is triggering the broadcast function more than once per request, often from a retried Lambda invocation after a timeout.
- High DynamoDB costs appear overnight: check for a loop accidentally calling
Scaninstead ofQuery, or a broadcast function running on every single connection-check instead of only on score changes. - wscat connects locally but production clients fail to connect: check CORS and WAF rules on the API Gateway stage, and confirm the client uses
wss://, notws://, for the production endpoint.
Security checklist before going live
Before pointing a real game client at this backend, run through a short checklist. None of these items are exotic, but skipping any one of them is a common way a side project turns into an incident report.
- IAM roles scoped per function. The submit-score function should not be able to delete the table, and the get-leaderboard function should not need write permissions at all. Review each
aws_iam_role_policyagainst the actions that Lambda function actually calls. - Authentication on both APIs. Add a Cognito user pool authorizer or a Lambda authorizer to the HTTP API and the WebSocket API’s
$connectroute so an anonymous client cannot submit scores as an arbitrary player ID. - Input validation on every field. The sample submit-score function checks type and range on
score; add length and character checks onplayerIdtoo, since it flows directly into a DynamoDB key. - Logging without leaking secrets. CloudWatch Logs are invaluable for debugging but should never contain raw authorization tokens or full request bodies in production; redact before logging.
- A WAF web ACL in front of the HTTP API for basic rate limiting and known-bad-IP blocking, especially before a public launch or a marketing push that could draw bot traffic.
Advanced tips: scaling beyond the basic build
Once the basic leaderboard works, a few upgrades matter at real scale. First, if your game has hundreds of thousands of concurrent WebSocket connections, consider fanning broadcasts out through SNS or EventBridge rather than looping over connections inside a single Lambda invocation. A single function has a 15-minute hard timeout and finite memory, and a loop over 100,000 connection records will not finish in time.
Second, for very read-heavy leaderboards, such as a popular game where thousands of players check rankings per minute but scores change relatively rarely, put a short-TTL cache in front of the get-leaderboard Lambda using API Gateway’s built-in caching or a small Redis layer, so you are not re-querying DynamoDB for an identical top-10 list from two different players within the same second.
Third, separate the broadcast trigger from the submit-score function using EventBridge so a slow or failing broadcast never delays the score-submission response the player’s game client is waiting on. The submit function should return success the instant the score is durably written, not after every connected client has received the push.
Complete project structure
With all fourteen steps complete, the finished project directory looks like this. Keeping each Lambda function in its own folder with its own package.json keeps deployment packages small, which matters for Lambda cold-start time, and makes it obvious which function owns which dependency.
serverless-leaderboard/
├── terraform/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── dynamodb.tf
│ ├── lambda.tf
│ ├── apigateway.tf
│ └── monitoring.tf
├── lambda/
│ ├── submit-score/
│ │ ├── index.mjs
│ │ └── package.json
│ ├── get-leaderboard/
│ │ ├── index.mjs
│ │ └── package.json
│ ├── ws-connect/
│ │ ├── index.mjs
│ │ └── package.json
│ ├── ws-disconnect/
│ │ ├── index.mjs
│ │ └── package.json
│ └── ws-broadcast/
│ ├── index.mjs
│ └── package.json
└── client/
└── index.html
The client/index.html file is a minimal test harness: a text input for a player ID and score, a submit button that calls the HTTP API, and a small script that opens the WebSocket connection and renders incoming leaderboard payloads into a list. It is intentionally not a real game UI. Its only job is proving the backend works before you wire it into an actual game client, whether that is a Unity project, a web game, or a mobile app.
Tearing down the project
Because everything is defined in Terraform, cleanup is a single command, which also makes this a safe project to test and iterate on without worrying about leaving billable resources running:
terraform -chdir=terraform destroy
Confirm the DynamoDB table, both API Gateway APIs, the Lambda functions, and the IAM role all disappear from the AWS console afterward. If point-in-time recovery was enabled and you want a final backup, export it before running destroy.
Frequently asked questions
Do I need Amazon GameLift for this, or does this replace it?
This replaces GameLift for leaderboard and ranking functionality specifically. GameLift is built for hosting authoritative, tick-based dedicated game server sessions, a different problem entirely. Many production games run both: GameLift, or a Kubernetes-based server fleet, for live match sessions, and this kind of serverless backend for leaderboards, profiles, and matchmaking metadata.
Can I use this architecture for a mobile game with millions of players?
Yes, with the scaling adjustments described in the advanced tips section, specifically decoupling the broadcast step from the write path with EventBridge or SNS once concurrent WebSocket connections climb into the tens of thousands.
Why DynamoDB instead of a relational database for the leaderboard?
DynamoDB’s on-demand scaling and native support for a sorted global secondary index map directly onto the leaderboard access pattern of writing one score and reading the top N sorted by score, without needing the manual index tuning or connection-pool management that a relational database at this request volume would require.
What happens if a player is offline when a broadcast fires?
The broadcast simply fails for that connection ID with a GoneException, which the code catches and ignores. When the player reconnects, have the client call the HTTP GET /leaderboard endpoint immediately on connect to get the current state, rather than waiting for the next broadcast.
Is API Gateway WebSocket the same as Socket.IO or raw WebSockets in Node.js?
No. API Gateway WebSocket APIs are stateless between messages from the Lambda function’s perspective. The connection is held open by API Gateway, but each message triggers a fresh, short-lived Lambda invocation rather than a long-running process holding the socket. All session state has to be persisted externally, typically in DynamoDB, as shown in this tutorial.
How do I add authentication so players can’t submit scores for other players?
Add an authorizer to both the HTTP and WebSocket APIs, either a Lambda authorizer or an Amazon Cognito user pool authorizer, and extract the verified player ID from the validated token inside the submit-score function instead of trusting a playerId field in the request body.
What is the realistic cost for a small indie game with a few thousand daily players?
Based on current published pricing for Lambda, DynamoDB on-demand, and API Gateway, a few thousand daily active players checking and submitting scores a handful of times each typically lands well under $10 a month in total backend cost, excluding WebSocket connection-minutes if players stay connected for long sessions.
Can this run on Azure or Google Cloud instead?
The same pattern maps to Azure Functions with Azure SignalR Service and Cosmos DB, or Google Cloud Functions with Firestore and a WebSocket-compatible service, but the specific Terraform resources and SDK calls in this tutorial are AWS-specific.





