Moorhuhn Serverless Game

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 GitHub

When 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.

Moorhuhn Serverless Architecture

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.

Moorhuhn Game In-Game Screenshot

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.

AWS Lambda Functions for Moorhuhn Game

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:

  1. Validates the score isn't impossible (anti-cheat)
  2. Stores the score in DynamoDB
  3. Queries the player's personal best
  4. Queries the global top 10
  5. 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 for New Records

SNS topic "moorhuhn-new-records" configured for high score notifications with standard message delivery

SQS Dead Letter Queue

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:

API Gateway REST Endpoints

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 Settings

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:

  1. Check prerequisites (Node.js 18+, AWS CLI, credentials)
  2. Install dependencies (infra, backend, frontend)
  3. Bootstrap CDK (if needed)
  4. Deploy infrastructure (cdk deploy)
  5. Build React frontend (npm run build)
  6. Sync frontend to S3
  7. Create CloudFront invalidation
  8. 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

Moorhuhn Game Main Menu and Leaderboard

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
Roman Čerešňák

Roman Čerešňák

AWS Golden Jacket · 14× AWS certified

Writing about cloud architecture, MLOps, and building production systems on AWS and Azure.