I Built a Serverless Game That Scales to 100K Players for $1.60/month
A production-ready browser game with real-time leaderboards, anti-cheat detection, and global CDN distribution — all built on AWS serverless for pennies per player.
romanceresnak/aws-morhoon
Serverless browser game · Phaser.js + Lambda + DynamoDB + CDK · One-command deployment
⭐ View on GitHubWhen people think "serverless," they usually imagine REST APIs, data pipelines, or ML inference. Not many think of building a real-time browser game where players compete on global leaderboards, the game detects cheaters automatically, and everything runs for the cost of a coffee per month.
I built exactly that — a browser-based shooting game inspired by the classic Moorhuhn, deployed entirely on serverless AWS. No EC2 instances. No Kubernetes clusters. No game servers to maintain. Just Lambda, DynamoDB, API Gateway, and CloudFront working together to deliver a playable game to players anywhere in the world.
Architecture Overview
The system is built on a fully serverless AWS stack. The frontend runs in the browser using Phaser.js 3.70, a mature game engine with hardware-accelerated Canvas/WebGL rendering. All game logic — chicken spawning, collision detection, particle effects — runs client-side. The backend is purely for score persistence and leaderboard queries.
Serverless game architecture: CloudFront delivers static assets, API Gateway handles score submissions, Lambda validates and stores data in DynamoDB
The architecture is intentionally simple. Players load the game from CloudFront with 10-50ms edge latency. After playing a 60-second session, they submit their score via API Gateway. Lambda validates the score (anti-cheat), stores it in DynamoDB, queries the leaderboard, and optionally publishes an SNS notification for new records.
Stack Layer Breakdown
| Layer | Service | Purpose |
|---|---|---|
| Frontend | React 18.2 + Phaser.js 3.70 | Browser-based game engine |
| Distribution | CloudFront + S3 | Global CDN with 450+ edge locations |
| API | API Gateway REST | Score submission + leaderboard endpoints |
| Compute | Lambda (Node.js 20.x) | Score validation + database queries |
| Database | DynamoDB (on-demand) | Score persistence + leaderboards |
| Notifications | SNS + SQS DLQ | New record alerts |
| Infrastructure | AWS CDK 2.120 (TypeScript) | Infrastructure as Code |
Frontend: Phaser.js Game Engine
Phaser.js handles all game logic client-side. The game is 517 lines of JavaScript with zero external image assets — all sprites are procedurally generated using Phaser's graphics API. This keeps bundle size tiny and eliminates loading times.
In-game view showing procedurally generated chickens, score tracking, accuracy percentage, and real-time timer
Game Mechanics
Players have 60 seconds to shoot chickens flying across the screen. There are four types, each with different speeds and point values:
| Type | Scale | Speed (px/s) | Points | Spawn Rate |
|---|---|---|---|---|
| Giant Golden | 2.5× | 80 | 300 | 15% |
| Large Brown | 1.8× | 120 | 150 | 20% |
| Normal | 1.2× | 180 | 100 | 35% |
| Small White | 0.7× | 280 | 50 | 30% |
The small white chickens are hardest to hit but worth the least. Giant golden chickens are slow and valuable, creating a risk-reward dynamic.
Procedural Graphics
All sprites are generated programmatically — chickens, crosshair, feathers. This gives full control over visual style and eliminates HTTP requests for image assets.
// Generate chicken sprite procedurally
function createChickenSprite(scene, color) {
const graphics = scene.add.graphics();
graphics.fillStyle(color, 1);
graphics.fillEllipse(0, 0, 30, 40); // Body
graphics.fillStyle(0xFFFFFF, 1);
graphics.fillCircle(-10, -20, 12); // Head
graphics.fillStyle(0xFFAA00, 1);
graphics.fillTriangle(-18, -20, -25, -18, -18, -16); // Beak
return graphics.generateTexture('chicken_' + color);
}
The game loads in under 200ms on a 3G connection. No textures to fetch, no sprite sheets to parse — everything is drawn in real-time.
Backend: Lambda Functions
Two Lambda functions handle all backend logic: score submission with anti-cheat validation, and leaderboard queries.
Three Lambda functions deployed in AWS: submit-score, get-leaderboard, and S3 auto-delete for cleanup
Submit Score Lambda (248 lines)
This function does five things:
- Validates the score isn't impossible (anti-cheat)
- Stores the score in DynamoDB
- Queries the player's personal best
- Queries the global top 10
- Publishes SNS notification if it's a new record
The anti-cheat logic is simple but effective:
const MAX_SCORE_PER_SECOND = 500; // Theoretical max with perfect accuracy
const scorePerSecond = score / gameTime;
if (scorePerSecond > MAX_SCORE_PER_SECOND) {
return {
statusCode: 400,
body: JSON.stringify({
error: 'Invalid score detected',
message: 'Score exceeds maximum possible rate'
})
};
}
if (score < 0) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Score cannot be negative' })
};
}
If a player claims they scored 50,000 points in 60 seconds, that's 833 points per second — impossible given the spawn rates. The server rejects it.
Get Leaderboard Lambda (163 lines)
This function queries DynamoDB using a Global Secondary Index optimized for leaderboard queries. It supports pagination, filtering, and optional user stats.
const params = {
TableName: process.env.SCORES_TABLE_NAME,
IndexName: 'ScoreIndex',
KeyConditionExpression: 'gameMode = :mode',
ExpressionAttributeValues: {
':mode': 'classic'
},
ScanIndexForward: false, // DESC order
Limit: limit || 10
};
const result = await dynamodb.query(params).promise();
return result.Items;
The GSI uses gameMode as partition key and score as sort key. DynamoDB returns top 10 scores in a single query without scanning the entire table.
Notifications: SNS + SQS
When a player achieves a new personal best or breaks into the top 10, the system publishes a notification to an SNS topic. A dead-letter queue (DLQ) captures failed notifications for debugging.
SNS topic "moorhuhn-new-records" configured for high score notifications with standard message delivery
Dead-letter queue for capturing failed notification deliveries with 14-day message retention
Database: DynamoDB Schema
The schema supports two access patterns: "get all scores for this user" and "get top N scores globally."
Primary Key
- Partition Key:
userId(String) - Sort Key:
timestamp(Number — Unix milliseconds)
Global Secondary Index: ScoreIndex
- Partition Key:
gameMode(String) - Sort Key:
score(Number, DESC) - Projection: ALL
Example Record
{
"userId": "user-abc123",
"timestamp": 1677123456789,
"score": 1450,
"gameTime": 60,
"gameMode": "classic",
"weapon": "shotgun",
"accuracy": 85,
"createdAt": "2024-02-23T10:30:56Z"
}
DynamoDB is configured with on-demand billing, point-in-time recovery (35 days), AWS-managed encryption, and RETAIN removal policy — running cdk destroy doesn't delete player data.
API Gateway: REST Endpoints
Two routes handle all game traffic:
REST API with two resource paths: /game/leaderboard (GET) and /game/score (POST) with CORS enabled
POST /game/score
Submit a score after completing a session.
{
"userId": "string (required)",
"score": "integer (required)",
"gameTime": "integer (required)",
"metadata": {
"accuracy": "integer (optional)",
"weapon": "string (optional)"
}
}
Response includes personal best status, top 10 status, and rank:
{
"message": "Score submitted successfully",
"score": 1450,
"isPersonalBest": true,
"isTopTen": true,
"rank": 3
}
GET /game/leaderboard
Fetch top scores with optional filters:
GET /game/leaderboard?limit=10&gameMode=classic&userId=user-123
API Gateway is configured with 1,000 req/s rate limit, 2,000 burst capacity, and CORS enabled for all origins.
Global Distribution: CloudFront + S3
The game frontend is hosted on S3 with CloudFront providing global distribution from 450+ edge locations.
CloudFront distribution with pay-as-you-go pricing, global CDN coverage, and automatic HTTPS
S3 Configuration
- Bucket:
moorhuhn-game-{AWS_ACCOUNT_ID} - Public Access: BLOCKED (CloudFront OAI only)
- Encryption: S3-managed (SSE-S3)
- Removal Policy: DESTROY + autoDeleteObjects
CloudFront Configuration
- Origin: S3 bucket via Origin Access Identity
- Protocol: HTTPS only (HTTP redirects to HTTPS)
- Compression: Gzip enabled
- Cache Policy: CACHING_OPTIMIZED
- Price Class: PRICE_CLASS_100 (US, EU, Israel)
- Error Pages: 404 → 200 (SPA routing)
The 404 → 200 redirect is critical for client-side routing. When users navigate to /leaderboard, CloudFront serves index.html with a 200 status, and React Router handles the route client-side.
Infrastructure as Code: AWS CDK
The entire infrastructure is defined in 252 lines of TypeScript using AWS CDK 2.120:
- DynamoDB table with GSI and PITR
- 2 Lambda functions with IAM roles
- API Gateway REST API with routes
- S3 bucket for static hosting
- CloudFront distribution with OAI
- SNS topic + SQS dead-letter queue
// DynamoDB table with GSI
const scoresTable = new dynamodb.Table(this, 'ScoresTable', {
partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'timestamp', type: dynamodb.AttributeType.NUMBER },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: cdk.RemovalPolicy.RETAIN
});
scoresTable.addGlobalSecondaryIndex({
indexName: 'ScoreIndex',
partitionKey: { name: 'gameMode', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'score', type: dynamodb.AttributeType.NUMBER },
projectionType: dynamodb.ProjectionType.ALL
});
One-Command Deployment
The repository includes setup-and-deploy.sh that automates the entire deployment:
- Check prerequisites (Node.js 18+, AWS CLI, credentials)
- Install dependencies (infra, backend, frontend)
- Bootstrap CDK (if needed)
- Deploy infrastructure (
cdk deploy) - Build React frontend (
npm run build) - Sync frontend to S3
- Create CloudFront invalidation
- Open browser to CloudFront URL
./setup-and-deploy.sh
Total deployment time: ~5 minutes. The script is idempotent — running it multiple times produces the same result without errors.
Performance Metrics
End-to-end latency for the critical path: score submission → validation → DynamoDB write → leaderboard query → response.
| Component | Cold Start | Warm Execution |
|---|---|---|
| CloudFront Edge Hit | 10-50ms | 10-50ms |
| API Gateway | 5-10ms | 5-10ms |
| Lambda Initialization | 500-1000ms | — |
| Lambda Execution | 10-50ms | 10-50ms |
| DynamoDB Write | 5-10ms | 5-10ms |
| DynamoDB Read (GSI) | 1-5ms | 1-5ms |
| Total | 600-1200ms | 50-100ms |
Cold starts are noticeable but only affect the first request after inactivity. With steady traffic, 95% of requests hit warm Lambdas and complete in under 100ms.
Cost Analysis: $0.00016 Per Player
For 10,000 players per month (5 games each = 50,000 sessions):
| Service | Usage | Monthly Cost |
|---|---|---|
| Lambda | 50,000 invocations × 100ms | $0.065 |
| API Gateway | 150,000 requests | $0.525 |
| DynamoDB (writes) | 50,000 WRU | $0.0625 |
| DynamoDB (reads) | 100,000 RRU | $0.025 |
| CloudFront | 10GB data transfer | $0.85 |
| S3 | Storage + requests | $0.072 |
| SNS | 50 notifications | $0.00005 |
| TOTAL | $1.60/month |
Cost per player: $0.00016. This is 300× cheaper than running a dedicated EC2 instance ($50/month minimum).
The real magic: $0 at zero usage. If nobody plays for a week, you pay nothing except S3 storage ($0.023/GB/month). Traditional game servers charge whether anyone is playing or not.
Try It Yourself
Game main menu featuring "Start Game" button, player ID, and real-time Top 10 leaderboard with scores and timestamps
The entire project is open source and deployable with a single command. Prerequisites: Node.js 18+, AWS CLI configured with credentials.
git clone https://github.com/romanceresnak/aws-morhoon.git
cd aws-morhoon
./setup-and-deploy.sh
The script outputs the CloudFront URL when deployment completes. Open it in a browser, shoot some chickens, submit your score, and see it appear on the global leaderboard in real-time.
To tear down (won't delete DynamoDB table due to RETAIN policy):
cd infra
cdk destroy