Deploy Serverless Snake Game on AWS

Goal

Build and deploy a complete serverless application on AWS using only the AWS Management Console. You’ll create a fully functional Snake game with a high score leaderboard, demonstrating real-world serverless architecture patterns without Infrastructure as Code.

What you’ll learn:

  • How to manually configure AWS serverless services in the console
  • When to use DynamoDB Global Secondary Indexes for efficient queries
  • Best practices for Lambda function configuration and IAM permissions
  • How to build and secure REST APIs with API Gateway and CORS
  • Techniques for hosting static websites on S3 with dynamic API integration

Prerequisites

Before starting, ensure you have:

  • ✓ AWS account with console access (free tier eligible)
  • ✓ Basic understanding of HTTP requests and JSON
  • ✓ Familiarity with Python programming language
  • ✓ Text editor for creating Lambda function code and HTML files

Exercise Steps

Overview

  1. Create DynamoDB Table with Global Secondary Index
  2. Create IAM Role for Lambda Functions
  3. Create GET Scores Lambda Function
  4. Create Submit Score Lambda Function
  5. Create API Gateway REST API
  6. Configure CORS and Deploy API
  7. Create S3 Bucket and Deploy Frontend
  8. Test Your Serverless Application

Step 1: Create DynamoDB Table with Global Secondary Index

Set up the database that will store player scores with an efficient index for retrieving top scores. DynamoDB is a fully managed NoSQL database that scales automatically and provides single-digit millisecond performance, making it perfect for gaming leaderboards.

  1. Navigate to the DynamoDB service in the AWS Console

  2. Click the “Create table” button

  3. Configure the table with these settings:

    • Table name: SnakeGameScores
    • Partition key: scoreId (String)
    • Table settings: Choose “Customize settings”
    • Table class: DynamoDB Standard
    • Read/write capacity settings: On-demand
  4. Scroll down to the “Secondary indexes” section

  5. Click “Create global index”

  6. Configure the Global Secondary Index:

    • Partition key: gameType (String)
    • Sort key: score (Number)
    • Index name: ScoreIndex
    • Attribute projections: All
  7. Click “Create index”

  8. Scroll to the bottom and click “Create table”

  9. Wait approximately 30 seconds for the table to become active

Concept Deep Dive

A Global Secondary Index (GSI) allows you to query data using attributes other than the primary key. Without the GSI, retrieving the top scores would require scanning the entire table, which is slow and expensive. The GSI with gameType as partition key and score as sort key enables efficient queries for top scores in descending order.

The scoreId primary key ensures each score entry is unique. Using a UUID prevents collisions even with concurrent submissions. The on-demand billing mode means you only pay for actual read/write requests, making it cost-effective for educational projects with variable traffic.

Common Mistakes

  • Using score as the primary key causes issues when multiple players have the same score
  • Forgetting to set the sort key type to “Number” prevents proper numerical sorting
  • Choosing provisioned capacity requires managing throughput settings manually

Quick check: Table status shows “Active” and the ScoreIndex appears in the Indexes tab

Step 2: Create IAM Role for Lambda Functions

Create an execution role that grants your Lambda functions permission to access DynamoDB and write logs. IAM roles provide secure, temporary credentials to AWS services without embedding access keys in your code.

  1. Navigate to the IAM service in the AWS Console

  2. Click “Roles” in the left sidebar

  3. Click “Create role”

  4. Select “AWS service” as the trusted entity type

  5. Choose “Lambda” from the use case dropdown

  6. Click “Next”

  7. Search for and select these policies (use the search box):

    • AmazonDynamoDBFullAccess
    • AWSLambdaBasicExecutionRole
  8. Click “Next”

  9. Configure the role name and description:

    • Role name: SnakeGameLambdaRole
    • Description: Allows Lambda functions to access DynamoDB and CloudWatch Logs
  10. Click “Create role”

Concept Deep Dive

The principle of least privilege suggests granting only necessary permissions. In production, you’d create a custom policy limiting DynamoDB access to only the SnakeGameScores table. However, for learning purposes, AmazonDynamoDBFullAccess simplifies setup.

AWSLambdaBasicExecutionRole provides permissions to create and write to CloudWatch Logs, which is essential for debugging. Without this, you won’t be able to see function errors or debug output.

IAM roles use temporary credentials that automatically rotate, which is more secure than using IAM user access keys. Lambda assumes this role when your function executes.

Common Mistakes

  • Forgetting AWSLambdaBasicExecutionRole prevents logging, making debugging impossible
  • Creating the role for the wrong service type causes trust policy errors
  • Not waiting for IAM changes to propagate (30-60 seconds) causes “access denied” errors

Quick check: Role appears in the roles list with both policies attached

Step 3: Create GET Scores Lambda Function

Build the Lambda function that retrieves the top 5 high scores from DynamoDB using the Global Secondary Index. This function demonstrates efficient querying patterns and proper error handling in serverless applications.

  1. Navigate to the Lambda service in the AWS Console

  2. Click “Create function”

  3. Configure the function:

    • Function name: GetScoresFunction
    • Runtime: Python 3.12
    • Architecture: x86_64
    • Permissions: Choose “Use an existing role”
    • Existing role: Select SnakeGameLambdaRole
  4. Click “Create function”

  5. Wait for the function to be created (about 5 seconds)

  6. Scroll down to the “Code source” section

  7. Delete all existing code in the editor

  8. Paste the following code:

    Lambda function: GetScoresFunction

    import json
    import boto3
    import os
    from decimal import Decimal
    
    # Initialize DynamoDB client
    dynamodb = boto3.resource('dynamodb')
    
    # Get table and index names from environment variables
    TABLE_NAME = os.environ.get('TABLE_NAME', 'SnakeGameScores')
    INDEX_NAME = os.environ.get('INDEX_NAME', 'ScoreIndex')
    
    def lambda_handler(event, context):
        """
        Get top 5 high scores from DynamoDB.
    
        Returns:
            JSON response with array of top 5 scores sorted by highest score first
        """
        try:
            # Get table reference
            table = dynamodb.Table(TABLE_NAME)
    
            # Query the GSI for top 5 scores
            # ScanIndexForward=False ensures descending order (highest scores first)
            response = table.query(
                IndexName=INDEX_NAME,
                KeyConditionExpression='gameType = :gameType',
                ExpressionAttributeValues={
                    ':gameType': 'snake'
                },
                ScanIndexForward=False,  # Descending order
                Limit=5
            )
    
            # Get items from response
            items = response.get('Items', [])
    
            # Convert Decimal types to int/float for JSON serialization
            def decimal_default(obj):
                if isinstance(obj, Decimal):
                    return int(obj) if obj % 1 == 0 else float(obj)
                raise TypeError
    
            # Format response
            scores = []
            for item in items:
                scores.append({
                    'playerName': item.get('playerName', 'anonymous'),
                    'score': int(item.get('score', 0)),
                    'timestamp': item.get('timestamp', '')
                })
    
            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*',
                    'Access-Control-Allow-Headers': 'Content-Type',
                    'Access-Control-Allow-Methods': 'GET,OPTIONS'
                },
                'body': json.dumps(scores, default=decimal_default)
            }
    
        except Exception as e:
            print(f"Error: {str(e)}")
            return {
                'statusCode': 500,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*',
                    'Access-Control-Allow-Headers': 'Content-Type',
                    'Access-Control-Allow-Methods': 'GET,OPTIONS'
                },
                'body': json.dumps({'error': 'Internal server error', 'message': str(e)})
            }
    
  9. Click “Deploy” to save the function code

  10. Click the “Configuration” tab

  11. Click “Environment variables” in the left menu

  12. Click “Edit”

  13. Add two environment variables:

    • Key: TABLE_NAME, Value: SnakeGameScores
    • Key: INDEX_NAME, Value: ScoreIndex
  14. Click “Save”

Concept Deep Dive

This Lambda function uses the AWS SDK for Python (boto3) to interact with DynamoDB. The query operation on the GSI is far more efficient than scanning the entire table. ScanIndexForward=False leverages DynamoDB’s sorted nature to return highest scores first.

DynamoDB stores numbers as Decimal type for precision, but JSON doesn’t support Decimal. The decimal_default function converts these to int or float for proper serialization. Without this conversion, the function would fail with a TypeError.

CORS headers must be included in the Lambda response, not just in API Gateway. The browser checks CORS headers from the actual response, so both layers must cooperate. The Access-Control-Allow-Origin: * makes this API public for educational purposes.

Environment variables externalize configuration, making the code portable. If you need to change the table name, you only update the environment variable, not the code.

Common Mistakes

  • Forgetting to click “Deploy” means changes aren’t saved
  • Typos in environment variable names cause the function to use default values
  • Missing CORS headers result in browser blocking the response
  • Not handling the Decimal conversion causes JSON serialization errors

Quick check: Function code is deployed and environment variables show both TABLE_NAME and INDEX_NAME

Step 4: Create Submit Score Lambda Function

Build the Lambda function that validates and stores new player scores in DynamoDB. This function demonstrates input validation, error handling, and proper API response formatting for serverless applications.

  1. Return to the Lambda functions list (click “Functions” in the breadcrumb)

  2. Click “Create function”

  3. Configure the function:

    • Function name: SubmitScoreFunction
    • Runtime: Python 3.12
    • Architecture: x86_64
    • Permissions: Choose “Use an existing role”
    • Existing role: Select SnakeGameLambdaRole
  4. Click “Create function”

  5. Scroll down to the “Code source” section

  6. Delete all existing code

  7. Paste the following code:

    Lambda function: SubmitScoreFunction

    import json
    import boto3
    import os
    import uuid
    from datetime import datetime
    
    # Initialize DynamoDB client
    dynamodb = boto3.resource('dynamodb')
    
    # Get table name from environment variable
    TABLE_NAME = os.environ.get('TABLE_NAME', 'SnakeGameScores')
    
    def lambda_handler(event, context):
        """
        Submit a new score to DynamoDB.
    
        Expects JSON body with:
            - playerName: string (max 20 chars, defaults to "anonymous" if empty)
            - score: integer (>= 0 and <= 10000)
    
        Returns:
            JSON response with success message and scoreId
        """
        try:
            # Parse request body
            if 'body' in event:
                body = json.loads(event['body']) if isinstance(event['body'], str) else event['body']
            else:
                body = event
    
            # Extract and validate player name
            player_name = body.get('playerName', '').strip()
            if not player_name:
                player_name = 'anonymous'
    
            # Truncate to 20 characters
            player_name = player_name[:20]
    
            # Extract and validate score
            score = body.get('score')
    
            # Validate score is present
            if score is None:
                return {
                    'statusCode': 400,
                    'headers': {
                        'Content-Type': 'application/json',
                        'Access-Control-Allow-Origin': '*',
                        'Access-Control-Allow-Headers': 'Content-Type',
                        'Access-Control-Allow-Methods': 'POST,OPTIONS'
                    },
                    'body': json.dumps({'error': 'Missing required field: score'})
                }
    
            # Validate score is a number
            try:
                score = int(score)
            except (ValueError, TypeError):
                return {
                    'statusCode': 400,
                    'headers': {
                        'Content-Type': 'application/json',
                        'Access-Control-Allow-Origin': '*',
                        'Access-Control-Allow-Headers': 'Content-Type',
                        'Access-Control-Allow-Methods': 'POST,OPTIONS'
                    },
                    'body': json.dumps({'error': 'Score must be an integer'})
                }
    
            # Validate score range
            if score < 0:
                return {
                    'statusCode': 400,
                    'headers': {
                        'Content-Type': 'application/json',
                        'Access-Control-Allow-Origin': '*',
                        'Access-Control-Allow-Headers': 'Content-Type',
                        'Access-Control-Allow-Methods': 'POST,OPTIONS'
                    },
                    'body': json.dumps({'error': 'Score must be >= 0'})
                }
    
            if score > 10000:
                return {
                    'statusCode': 400,
                    'headers': {
                        'Content-Type': 'application/json',
                        'Access-Control-Allow-Origin': '*',
                        'Access-Control-Allow-Headers': 'Content-Type',
                        'Access-Control-Allow-Methods': 'POST,OPTIONS'
                    },
                    'body': json.dumps({'error': 'Score must be <= 10000'})
                }
    
            # Generate unique scoreId
            score_id = str(uuid.uuid4())
    
            # Create timestamp
            timestamp = datetime.utcnow().isoformat() + 'Z'
    
            # Get table reference
            table = dynamodb.Table(TABLE_NAME)
    
            # Put item in DynamoDB
            table.put_item(
                Item={
                    'scoreId': score_id,
                    'gameType': 'snake',
                    'playerName': player_name,
                    'score': score,
                    'timestamp': timestamp
                }
            )
    
            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*',
                    'Access-Control-Allow-Headers': 'Content-Type',
                    'Access-Control-Allow-Methods': 'POST,OPTIONS'
                },
                'body': json.dumps({
                    'message': 'Score submitted successfully',
                    'scoreId': score_id,
                    'playerName': player_name,
                    'score': score
                })
            }
    
        except Exception as e:
            print(f"Error: {str(e)}")
            return {
                'statusCode': 500,
                'headers': {
                    'Content-Type': 'application/json',
                    'Access-Control-Allow-Origin': '*',
                    'Access-Control-Allow-Headers': 'Content-Type',
                    'Access-Control-Allow-Methods': 'POST,OPTIONS'
                },
                'body': json.dumps({'error': 'Internal server error', 'message': str(e)})
            }
    
  8. Click “Deploy”

  9. Click the “Configuration” tab

  10. Click “Environment variables”

  11. Click “Edit”

  12. Add environment variable:

    • Key: TABLE_NAME, Value: SnakeGameScores
  13. Click “Save”

Concept Deep Dive

Input validation is critical for data integrity and security. This function validates both the presence and format of data before storing it. The score range validation (0-10000) prevents unrealistic values from polluting the leaderboard.

The function handles both API Gateway proxy integration format (with body field) and direct invocation format. This flexibility makes the function easier to test in the Lambda console.

UUID v4 generates cryptographically random identifiers with extremely low collision probability. This is better than sequential IDs because it prevents enumeration attacks and works correctly with distributed systems.

The timestamp uses ISO 8601 format with UTC timezone (the ‘Z’ suffix). This standard format ensures consistent sorting and parsing across different systems and timezones.

All error responses include CORS headers. This is crucial because the browser checks CORS headers even for error responses. Without CORS headers on errors, the browser blocks the response and the frontend can’t display proper error messages.

Common Mistakes

  • Not validating input allows invalid data in your database
  • Missing TABLE_NAME environment variable causes function to fail
  • Forgetting CORS headers on error responses breaks frontend error handling
  • Using datetime.now() instead of datetime.utcnow() creates timezone confusion

Quick check: Function deploys successfully and environment variable TABLE_NAME is configured

Step 5: Create API Gateway REST API

Create a REST API that exposes your Lambda functions as HTTP endpoints. API Gateway handles request routing, validation, and integration with backend services, providing a managed interface between clients and your serverless functions.

  1. Navigate to the API Gateway service in the AWS Console

  2. Click “Create API”

  3. Find “REST API” (not REST API Private)

  4. Click “Build” under REST API

  5. Configure the API:

    • Choose the protocol: REST
    • Create new API: New API
    • API name: SnakeGameAPI
    • Description: REST API for Snake Game leaderboard
    • Endpoint Type: Regional
  6. Click “Create API”

  7. Create the /scores resource:

    • Click “Actions” dropdown
    • Select “Create Resource”
    • Resource Name: scores
    • Resource Path: /scores (auto-filled)
    • Enable CORS: Leave unchecked (we’ll configure manually)
    • Click “Create Resource”
  8. Create the GET method:

    • Ensure /scores resource is selected in the tree
    • Click “Actions” dropdown
    • Select “Create Method”
    • Choose “GET” from the dropdown that appears
    • Click the checkmark
    • Configure the GET method:
      • Integration type: Lambda Function
      • Use Lambda Proxy integration: Check this box
      • Lambda Region: Select your region (e.g., eu-west-1)
      • Lambda Function: Type GetScoresFunction
      • Click “Save”
    • Click “OK” to grant API Gateway permission to invoke Lambda
  9. Create the POST method:

    • Ensure /scores resource is still selected
    • Click “Actions” dropdown
    • Select “Create Method”
    • Choose “POST” from the dropdown
    • Click the checkmark
    • Configure the POST method:
      • Integration type: Lambda Function
      • Use Lambda Proxy integration: Check this box
      • Lambda Region: Select your region
      • Lambda Function: Type SubmitScoreFunction
      • Click “Save”
    • Click “OK” to grant permission
  10. Create the OPTIONS method for CORS:

    • Click “Actions” dropdown
    • Select “Create Method”
    • Choose “OPTIONS” from the dropdown
    • Click the checkmark
    • Configure the OPTIONS method:
      • Integration type: Mock
      • Click “Save”

Concept Deep Dive

API Gateway provides a fully managed API layer that handles throttling, caching, authentication, and monitoring. The REST API type offers complete control over request/response transformations, making it suitable for production applications.

Lambda Proxy integration passes the entire HTTP request to the Lambda function as a JSON event, including headers, query parameters, and body. This gives your function complete control over the response, including status codes and headers.

The OPTIONS method is required for CORS preflight requests. Modern browsers send an OPTIONS request before cross-origin POST/PUT/DELETE requests to check if the operation is allowed. The Mock integration returns a response directly without calling a backend service.

Regional endpoints are suitable for most applications and are cheaper than Edge-optimized endpoints. Use Edge-optimized only if your users are globally distributed and you need CloudFront distribution.

Common Mistakes

  • Forgetting to check “Lambda Proxy integration” causes incorrect event format
  • Not granting Lambda invoke permissions results in 500 errors
  • Missing OPTIONS method breaks CORS for POST requests
  • Typos in Lambda function names cause integration errors

Quick check: All three methods (GET, POST, OPTIONS) appear under the /scores resource

Step 6: Configure CORS and Deploy API

Configure Cross-Origin Resource Sharing (CORS) headers to allow browser requests from your frontend, then deploy the API to make it accessible. Without proper CORS configuration, browsers block requests from your frontend to the API.

  1. Configure OPTIONS method response:

    • Click the OPTIONS method under /scores
    • Click “Method Response”
    • Click “Add Response”
    • Enter 200 for HTTP status
    • Click the checkmark
    • Expand the “200” response
    • Add response headers:
      • Click “Add Header”, enter Access-Control-Allow-Origin, click checkmark
      • Click “Add Header”, enter Access-Control-Allow-Headers, click checkmark
      • Click “Add Header”, enter Access-Control-Allow-Methods, click checkmark
  2. Configure OPTIONS integration response:

    • Click “Integration Response” tab
    • Expand the “200” response
    • Expand “Header Mappings”
    • Set header values:
      • Access-Control-Allow-Origin: '*' (include single quotes)
      • Access-Control-Allow-Headers: 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
      • Access-Control-Allow-Methods: 'GET,POST,OPTIONS'
  3. Deploy the API:

    • Click “Actions” dropdown
    • Select “Deploy API”
    • Deployment stage: [New Stage]
    • Stage name: prod
    • Stage description: Production stage
    • Click “Deploy”
  4. Copy the Invoke URL that appears at the top (format: https://[api-id].execute-api.[region].amazonaws.com/prod)

  5. Save this URL - you’ll need it for the frontend configuration

Concept Deep Dive

CORS is a security mechanism that prevents malicious websites from making requests to your API from a user’s browser. The browser first sends an OPTIONS request (preflight) asking “Can I make this cross-origin request?” The server responds with allowed origins, methods, and headers.

The '*' value for Access-Control-Allow-Origin allows any origin to call your API. This is acceptable for public APIs and educational projects. In production with authentication, you’d specify exact origins like 'https://mywebsite.com'.

The single quotes around header values in API Gateway Integration Response are required. API Gateway uses these as literal strings in the HTTP response. Without quotes, it treats them as variables.

API Gateway stages allow you to maintain multiple versions of your API (dev, test, prod). Each stage has its own invoke URL and can have different settings like throttling limits and logging levels.

Lambda functions also return CORS headers. Both are needed: API Gateway handles the OPTIONS preflight, while Lambda handles actual request responses (GET, POST).

Common Mistakes

  • Forgetting single quotes around header values causes CORS failures
  • Not deploying the API after changes means old configuration is still active
  • Copying the wrong URL format (needs /prod stage at the end)
  • Only setting CORS in API Gateway without Lambda headers breaks POST requests

Quick check: Invoke URL is visible and includes the stage name (e.g., …/prod)

Step 7: Create S3 Bucket and Deploy Frontend

Set up static website hosting on S3 to serve your game’s frontend files. S3 provides highly available, scalable storage perfect for hosting single-page applications with minimal cost.

  1. Navigate to the S3 service in the AWS Console

  2. Click “Create bucket”

  3. Configure bucket settings:

    • Bucket name: snake-game-[your-unique-id] (replace with something unique like your initials and date)
    • AWS Region: Same region as your other resources
    • Object Ownership: ACLs disabled
    • Block Public Access settings: UNCHECK “Block all public access”
    • Acknowledge the warning about public access
    • Bucket Versioning: Disable
    • Default encryption: Server-side encryption with Amazon S3 managed keys (SSE-S3)
  4. Click “Create bucket”

  5. Click on your newly created bucket name

  6. Enable static website hosting:

    • Click the “Properties” tab
    • Scroll down to “Static website hosting”
    • Click “Edit”
    • Static website hosting: Enable
    • Hosting type: Host a static website
    • Index document: index.html
    • Click “Save changes”
  7. Configure bucket policy for public read access:

    • Click the “Permissions” tab
    • Scroll down to “Bucket policy”
    • Click “Edit”
    • Paste this policy (replace YOUR-BUCKET-NAME with your actual bucket name):

    S3 Bucket Policy

    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "PublicReadGetObject",
                "Effect": "Allow",
                "Principal": "*",
                "Action": "s3:GetObject",
                "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
            }
        ]
    }
    
    • Click “Save changes”
  8. Prepare the frontend file:

    • Create a new file named index.html in your text editor
    • Paste the following complete HTML code:

    Frontend file: index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Snake Game - Serverless Demo</title>
        <style>
            * {
                margin: 0;
                padding: 0;
                box-sizing: border-box;
            }
    
            body {
                font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
                display: flex;
                justify-content: center;
                align-items: center;
                min-height: 100vh;
                padding: 20px;
            }
    
            .container {
                background: white;
                border-radius: 20px;
                padding: 30px;
                box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
                max-width: 900px;
                width: 100%;
            }
    
            h1 {
                text-align: center;
                color: #667eea;
                margin-bottom: 20px;
                font-size: 2.5em;
            }
    
            .game-container {
                display: grid;
                grid-template-columns: 1fr 300px;
                gap: 30px;
                margin-top: 20px;
            }
    
            @media (max-width: 768px) {
                .game-container {
                    grid-template-columns: 1fr;
                }
            }
    
            .game-area {
                display: flex;
                flex-direction: column;
                align-items: center;
            }
    
            #gameCanvas {
                border: 3px solid #667eea;
                border-radius: 10px;
                background: #f0f0f0;
                box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            }
    
            .game-info {
                margin-top: 20px;
                text-align: center;
                width: 100%;
            }
    
            .score-display {
                font-size: 1.5em;
                font-weight: bold;
                color: #667eea;
                margin-bottom: 15px;
            }
    
            .player-input {
                margin: 15px 0;
            }
    
            .player-input input {
                width: 100%;
                padding: 12px;
                border: 2px solid #667eea;
                border-radius: 8px;
                font-size: 1em;
                transition: all 0.3s;
            }
    
            .player-input input:focus {
                outline: none;
                border-color: #764ba2;
                box-shadow: 0 0 0 3px rgba(118, 75, 162, 0.1);
            }
    
            button {
                background: #667eea;
                color: white;
                border: none;
                padding: 12px 30px;
                border-radius: 8px;
                font-size: 1em;
                font-weight: bold;
                cursor: pointer;
                transition: all 0.3s;
                margin: 5px;
            }
    
            button:hover {
                background: #764ba2;
                transform: translateY(-2px);
                box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
            }
    
            button:active {
                transform: translateY(0);
            }
    
            button:disabled {
                background: #ccc;
                cursor: not-allowed;
                transform: none;
            }
    
            .controls-info {
                margin-top: 15px;
                padding: 15px;
                background: #f8f9fa;
                border-radius: 8px;
                font-size: 0.9em;
                color: #666;
            }
    
            .controls-info h3 {
                margin-bottom: 10px;
                color: #667eea;
            }
    
            .leaderboard {
                background: #f8f9fa;
                border-radius: 10px;
                padding: 20px;
            }
    
            .leaderboard h2 {
                color: #667eea;
                margin-bottom: 15px;
                text-align: center;
            }
    
            .leaderboard-list {
                list-style: none;
            }
    
            .leaderboard-item {
                padding: 12px;
                margin: 8px 0;
                background: white;
                border-radius: 8px;
                display: flex;
                justify-content: space-between;
                align-items: center;
                box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            }
    
            .leaderboard-rank {
                font-weight: bold;
                color: #667eea;
                font-size: 1.2em;
                min-width: 30px;
            }
    
            .leaderboard-name {
                flex-grow: 1;
                margin: 0 10px;
                font-weight: 500;
            }
    
            .leaderboard-score {
                font-weight: bold;
                color: #764ba2;
            }
    
            .game-over {
                text-align: center;
                padding: 20px;
                background: #fff3cd;
                border-radius: 10px;
                margin-top: 15px;
                border: 2px solid #ffc107;
            }
    
            .game-over h2 {
                color: #856404;
                margin-bottom: 10px;
            }
    
            .status-message {
                margin-top: 10px;
                padding: 10px;
                border-radius: 5px;
                font-size: 0.9em;
            }
    
            .status-message.success {
                background: #d4edda;
                color: #155724;
                border: 1px solid #c3e6cb;
            }
    
            .status-message.error {
                background: #f8d7da;
                color: #721c24;
                border: 1px solid #f5c6cb;
            }
    
            .loading {
                text-align: center;
                color: #667eea;
                font-style: italic;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <h1>Snake Game</h1>
    
            <div class="game-container">
                <div class="game-area">
                    <canvas id="gameCanvas" width="400" height="400"></canvas>
    
                    <div class="game-info">
                        <div class="score-display">
                            Score: <span id="score">0</span>
                        </div>
    
                        <div class="player-input">
                            <input type="text" id="playerName" placeholder="Enter your name (or leave blank for 'anonymous')" maxlength="20">
                        </div>
    
                        <div>
                            <button id="startButton" onclick="startGame()">Start Game</button>
                            <button id="submitButton" onclick="submitScore()" style="display:none;">Submit Score</button>
                        </div>
    
                        <div id="statusMessage" class="status-message" style="display:none;"></div>
    
                        <div class="controls-info">
                            <h3>How to Play</h3>
                            <p><strong>Arrow Keys:</strong> Control snake direction</p>
                            <p><strong>Goal:</strong> Eat food to grow and increase score</p>
                            <p><strong>Avoid:</strong> Walls and your own tail</p>
                        </div>
                    </div>
                </div>
    
                <div class="leaderboard">
                    <h2>Top 5 High Scores</h2>
                    <ul id="leaderboardList" class="leaderboard-list">
                        <li class="loading">Loading...</li>
                    </ul>
                </div>
            </div>
        </div>
    
        <script>
            // API Configuration
            const API_ENDPOINT = '{{API_ENDPOINT}}';
    
            // Game configuration
            const canvas = document.getElementById('gameCanvas');
            const ctx = canvas.getContext('2d');
            const gridSize = 20;
            const tileCount = canvas.width / gridSize;
    
            // Game state
            let snake = [{x: 10, y: 10}];
            let food = {x: 15, y: 15};
            let dx = 0;
            let dy = 0;
            let score = 0;
            let gameRunning = false;
            let gameLoop = null;
    
            // Initialize
            document.addEventListener('DOMContentLoaded', () => {
                loadLeaderboard();
                drawGame();
            });
    
            // Keyboard controls
            document.addEventListener('keydown', (e) => {
                if (!gameRunning) return;
    
                switch(e.key) {
                    case 'ArrowUp':
                        if (dy === 0) { dx = 0; dy = -1; }
                        e.preventDefault();
                        break;
                    case 'ArrowDown':
                        if (dy === 0) { dx = 0; dy = 1; }
                        e.preventDefault();
                        break;
                    case 'ArrowLeft':
                        if (dx === 0) { dx = -1; dy = 0; }
                        e.preventDefault();
                        break;
                    case 'ArrowRight':
                        if (dx === 0) { dx = 1; dy = 0; }
                        e.preventDefault();
                        break;
                }
            });
    
            function startGame() {
                // Reset game state
                snake = [{x: 10, y: 10}];
                food = generateFood();
                dx = 1;
                dy = 0;
                score = 0;
                gameRunning = true;
    
                document.getElementById('score').textContent = score;
                document.getElementById('startButton').style.display = 'none';
                document.getElementById('submitButton').style.display = 'none';
                document.getElementById('statusMessage').style.display = 'none';
    
                // Start game loop (200ms = slower, more playable speed)
                if (gameLoop) clearInterval(gameLoop);
                gameLoop = setInterval(update, 200);
            }
    
            function update() {
                if (!gameRunning) return;
    
                // Move snake
                const head = {x: snake[0].x + dx, y: snake[0].y + dy};
    
                // Check wall collision
                if (head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount) {
                    endGame();
                    return;
                }
    
                // Check self collision
                for (let segment of snake) {
                    if (head.x === segment.x && head.y === segment.y) {
                        endGame();
                        return;
                    }
                }
    
                // Add new head
                snake.unshift(head);
    
                // Check food collision
                if (head.x === food.x && head.y === food.y) {
                    score += 10;
                    document.getElementById('score').textContent = score;
                    food = generateFood();
                } else {
                    // Remove tail if no food eaten
                    snake.pop();
                }
    
                drawGame();
            }
    
            function drawGame() {
                // Clear canvas
                ctx.fillStyle = '#f0f0f0';
                ctx.fillRect(0, 0, canvas.width, canvas.height);
    
                // Draw grid
                ctx.strokeStyle = '#e0e0e0';
                ctx.lineWidth = 1;
                for (let i = 0; i <= tileCount; i++) {
                    ctx.beginPath();
                    ctx.moveTo(i * gridSize, 0);
                    ctx.lineTo(i * gridSize, canvas.height);
                    ctx.stroke();
                    ctx.beginPath();
                    ctx.moveTo(0, i * gridSize);
                    ctx.lineTo(canvas.width, i * gridSize);
                    ctx.stroke();
                }
    
                // Draw snake
                snake.forEach((segment, index) => {
                    ctx.fillStyle = index === 0 ? '#667eea' : '#764ba2';
                    ctx.fillRect(segment.x * gridSize, segment.y * gridSize, gridSize - 2, gridSize - 2);
                });
    
                // Draw food
                ctx.fillStyle = '#ff6b6b';
                ctx.beginPath();
                ctx.arc(
                    food.x * gridSize + gridSize / 2,
                    food.y * gridSize + gridSize / 2,
                    gridSize / 2 - 2,
                    0,
                    Math.PI * 2
                );
                ctx.fill();
            }
    
            function generateFood() {
                let newFood;
                do {
                    newFood = {
                        x: Math.floor(Math.random() * tileCount),
                        y: Math.floor(Math.random() * tileCount)
                    };
                } while (snake.some(segment => segment.x === newFood.x && segment.y === newFood.y));
                return newFood;
            }
    
            function endGame() {
                gameRunning = false;
                clearInterval(gameLoop);
    
                document.getElementById('startButton').style.display = 'inline-block';
                document.getElementById('submitButton').style.display = 'inline-block';
    
                showMessage('Game Over! Final Score: ' + score, 'error');
            }
    
            async function submitScore() {
                const playerName = document.getElementById('playerName').value.trim() || 'anonymous';
                const submitButton = document.getElementById('submitButton');
    
                submitButton.disabled = true;
                showMessage('Submitting score...', 'success');
    
                try {
                    const response = await fetch(`${API_ENDPOINT}/scores`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify({
                            playerName: playerName,
                            score: score
                        })
                    });
    
                    if (response.ok) {
                        showMessage('Score submitted successfully!', 'success');
                        await loadLeaderboard();
                        document.getElementById('submitButton').style.display = 'none';
                    } else {
                        const error = await response.json();
                        showMessage('Error: ' + (error.error || 'Failed to submit score'), 'error');
                        submitButton.disabled = false;
                    }
                } catch (error) {
                    showMessage('Network error: ' + error.message, 'error');
                    submitButton.disabled = false;
                }
            }
    
            async function loadLeaderboard() {
                const leaderboardList = document.getElementById('leaderboardList');
                leaderboardList.innerHTML = '<li class="loading">Loading...</li>';
    
                try {
                    const response = await fetch(`${API_ENDPOINT}/scores`);
    
                    if (!response.ok) {
                        throw new Error('Failed to load leaderboard');
                    }
    
                    const scores = await response.json();
    
                    if (scores.length === 0) {
                        leaderboardList.innerHTML = '<li class="loading">No scores yet. Be the first!</li>';
                        return;
                    }
    
                    leaderboardList.innerHTML = '';
                    scores.forEach((entry, index) => {
                        const li = document.createElement('li');
                        li.className = 'leaderboard-item';
                        li.innerHTML = `
                            <span class="leaderboard-rank">#${index + 1}</span>
                            <span class="leaderboard-name">${escapeHtml(entry.playerName)}</span>
                            <span class="leaderboard-score">${entry.score}</span>
                        `;
                        leaderboardList.appendChild(li);
                    });
                } catch (error) {
                    leaderboardList.innerHTML = '<li class="loading">Failed to load leaderboard</li>';
                    console.error('Leaderboard error:', error);
                }
            }
    
            function showMessage(message, type) {
                const statusMessage = document.getElementById('statusMessage');
                statusMessage.textContent = message;
                statusMessage.className = 'status-message ' + type;
                statusMessage.style.display = 'block';
            }
    
            function escapeHtml(text) {
                const div = document.createElement('div');
                div.textContent = text;
                return div.innerHTML;
            }
        </script>
    </body>
    </html>
    
    • Find the line containing {{API_ENDPOINT}} (around line 270 in the code above)
    • Replace {{API_ENDPOINT}} with your actual API Gateway Invoke URL (from Step 6)
    • Ensure the URL format is: https://[api-id].execute-api.[region].amazonaws.com/prod (without /scores at the end)
    • Save the file
  9. Upload the frontend:

    • Click the “Objects” tab in your bucket
    • Click “Upload”
    • Click “Add files”
    • Select your modified index.html file
    • Expand “Properties” section
    • Set Content-Type to text/html (usually auto-detected)
    • Click “Upload”
  10. Get your website URL:

    • Return to the bucket properties
    • Scroll to “Static website hosting”
    • Copy the “Bucket website endpoint” URL
  11. Open the website URL in your browser

Concept Deep Dive

S3 static website hosting serves files directly from S3 without requiring web servers. This architecture is highly scalable, reliable, and cost-effective. You only pay for storage ($0.023/GB/month) and data transfer.

The bucket policy grants public read access to all objects in the bucket. The /* in the Resource ARN means “all objects” while the ARN without /* refers only to the bucket itself. The Principal * means “everyone.”

Disabling “Block all public access” is necessary for static website hosting because the website must be publicly accessible. In production, you’d typically use CloudFront distribution with Origin Access Identity for better security and caching.

The index document setting tells S3 which file to serve when someone visits the root URL. Without this, accessing the bucket URL would show an XML error instead of your website.

Template variables like {{API_ENDPOINT}} allow the same HTML file to work in different environments (dev, staging, prod) by substituting values at deployment time. This is a common pattern in continuous deployment pipelines.

Common Mistakes

  • Forgetting to unblock public access prevents website from being accessible
  • Typo in bucket policy ARN causes “Invalid resource” error
  • Not replacing {{API_ENDPOINT}} means the frontend can’t reach your API
  • Including /prod in the substitution but then adding it again creates wrong URL
  • Accessing via S3 object URL instead of website endpoint URL causes issues

Quick check: Website loads in browser showing the Snake game interface

Step 8: Test Your Serverless Application

Verify that all components work together correctly by testing the complete user journey from playing the game to submitting scores and viewing the leaderboard. This systematic testing ensures your serverless architecture functions as designed.

  1. Test the website loads:

    • Open your S3 website endpoint URL in a browser
    • Verify the game canvas displays
    • Check browser console for any errors (F12 → Console tab)
  2. Test game functionality:

    • Press arrow keys or WASD to move the snake
    • Verify the snake responds to controls
    • Collect food items to increase score
    • Verify score updates in real-time
  3. Test score submission:

    • Play the game until game over (or intentionally lose)
    • Enter your name in the player input field
    • Click “Submit Score”
    • Verify success message appears
  4. Test leaderboard retrieval:

    • Check if your score appears in the leaderboard (right side)
    • Verify scores are sorted from highest to lowest
    • Submit multiple scores to see the top 5 filter working
  5. Test API directly with curl (optional but recommended):

    Test GET endpoint:

    curl https://YOUR-API-URL/prod/scores
    

    Expected response: JSON array of score objects

    Test POST endpoint:

    curl -X POST https://YOUR-API-URL/prod/scores \
      -H "Content-Type: application/json" \
      -d '{"playerName":"TestPlayer","score":9999}'
    

    Expected response: Success message with scoreId

  6. Test CORS by checking Network tab:

    • Open browser DevTools (F12)
    • Click “Network” tab
    • Submit a score
    • Click the request to /scores
    • Verify “Response Headers” include:
      • access-control-allow-origin: *
      • access-control-allow-methods: POST,OPTIONS
  7. Verify DynamoDB data:

    • Navigate to DynamoDB console
    • Click on SnakeGameScores table
    • Click “Explore table items”
    • Verify your submitted scores appear with all fields:
      • scoreId (UUID)
      • gameType (snake)
      • playerName (your input or “anonymous”)
      • score (integer)
      • timestamp (ISO 8601 format)
  8. Test validation:

    • Submit a score without a player name → should default to “anonymous”
    • Try to submit an invalid score via curl:
    curl -X POST https://YOUR-API-URL/prod/scores \
      -H "Content-Type: application/json" \
      -d '{"playerName":"Test","score":-100}'
    

    Expected: 400 error with message “Score must be >= 0”

Success indicators:

  • Game loads and responds to controls smoothly
  • Scores submit successfully and appear in leaderboard
  • Leaderboard displays top 5 scores in descending order
  • API responds correctly to both valid and invalid requests
  • CORS headers are present in all responses
  • DynamoDB contains score entries with proper data types
  • No JavaScript errors in browser console
  • curl tests return expected JSON responses

Final verification checklist:

  • ☐ DynamoDB table exists with ScoreIndex GSI
  • ☐ IAM role has DynamoDB and logging permissions
  • ☐ Both Lambda functions deployed with correct code
  • ☐ Environment variables configured in Lambda functions
  • ☐ API Gateway has all three methods (GET, POST, OPTIONS)
  • ☐ CORS configured in both API Gateway and Lambda responses
  • ☐ API deployed to prod stage
  • ☐ S3 bucket configured for static website hosting
  • ☐ Bucket policy allows public read access
  • ☐ index.html has correct API endpoint URL
  • ☐ Complete game-to-leaderboard flow works end-to-end

Common Issues

If you encounter problems:

“CORS error” or “blocked by CORS policy” in browser:

  • Verify OPTIONS method exists in API Gateway
  • Check both Lambda functions return CORS headers in all responses
  • Ensure CORS headers in OPTIONS integration response have single quotes
  • Verify API is deployed after making changes

“Internal server error” when submitting scores:

  • Check Lambda function logs in CloudWatch Logs
  • Verify IAM role has DynamoDB permissions
  • Ensure TABLE_NAME environment variable is set correctly
  • Check DynamoDB table name matches exactly (case-sensitive)

Leaderboard shows empty or old data:

  • Verify API Gateway URL in index.html is correct
  • Check browser DevTools Network tab for failed requests
  • Ensure DynamoDB table has data (check in console)
  • Verify GetScoresFunction has INDEX_NAME environment variable

“Access Denied” when accessing S3 website:

  • Verify bucket policy is correctly configured
  • Ensure “Block all public access” is turned OFF
  • Check you’re using website endpoint URL, not object URL
  • Wait 30-60 seconds for policy changes to propagate

API Gateway returns “Missing Authentication Token”:

  • Verify API is deployed to prod stage
  • Check URL includes stage name (e.g., /prod/scores)
  • Ensure resource path is exactly /scores
  • Verify methods are created under correct resource

Lambda function timeout errors:

  • Check function has IAM permissions to access DynamoDB
  • Verify DynamoDB table exists in same region
  • Ensure table name environment variable has no typos
  • Check CloudWatch Logs for specific error details

Still stuck?

  • Check CloudWatch Logs for both Lambda functions (Lambda → Functions → [Function Name] → Monitor → View logs in CloudWatch)
  • Verify all resources are in the same AWS region
  • Test Lambda functions directly in console with test events before testing via API Gateway
  • Use browser DevTools Network tab to inspect exact error responses

Summary

You’ve successfully deployed a complete serverless application on AWS using only the console which:

Key takeaway: Understanding manual configuration in the AWS Console is essential for debugging and troubleshooting, even when you use Infrastructure as Code in production. You’ll encounter situations where you need to inspect resources in the console, understand how services integrate, or manually fix configuration issues. This hands-on experience builds the foundation for working effectively with AWS serverless architectures.

Going Deeper (Optional)

Want to explore more?

  • Add CloudWatch Dashboards to monitor API requests, Lambda invocations, and DynamoDB reads/writes
  • Implement API Gateway request validation to reject malformed requests before Lambda execution
  • Add DynamoDB Time-to-Live (TTL) to automatically expire old scores after 30 days
  • Create a CloudFront distribution in front of S3 for global content delivery and HTTPS
  • Implement Lambda function versions and aliases for blue-green deployments
  • Add AWS X-Ray tracing to visualize request flow through your serverless architecture
  • Set up CloudWatch Alarms for Lambda errors and API Gateway 5xx responses
  • Explore DynamoDB auto-scaling for provisioned capacity mode
  • Add API Gateway usage plans and API keys for rate limiting
  • Implement AWS WAF to protect against common web exploits

Done! 🎉

Excellent work! You’ve learned how to manually deploy a complete serverless application on AWS and gained deep understanding of how each service works and integrates with others. This hands-on console experience will make you more effective when working with Infrastructure as Code tools like CloudFormation, Terraform, or AWS CDK, because you understand exactly what resources are being created and how they’re configured.