Earlier this month, I couldn't help but think, what if I could have a mini AI-Powered Dungeon Master (DM)? What if I could run game-ready DM in the cloud that remembers your character, generates quests, and scales for free-tier fun? Well, with some finagling (utilizing Amazon Bedrock Agents and serverless building blocks), I created my prototype.
In this walkthrough, I’ll show you how I built this Serverless DM Agent using Amazon Bedrock, Lambda, DynamoDB, API Gateway, and S3. You’ll learn the architecture, see the code, and be able to deploy it yourself with AWS CDK.
And on top of it all, it costs less than a monthly coffee subscription!
Amazon Bedrock Meets Serverless Architecture
The Serverless Dungeon Master Agent is a proof-of-concept that combines Amazon Bedrock's AI capabilities with a fully serverless AWS architecture. Here's what makes it special:
Persistent Character Memory - The AI DM remembers characters stats, inventory, backstory, and every decision you've made. No more "Wait, what happened last session?"
Dynamic Storytelling The AI generates narratives on the fly, adapting to choices and creating unique storylines that respond to characters actions.
Real-time Streaming Responses stream back naturally, creating an immersive conversation flow that feels like chatting with a human DM.
Cost-Effective Gaming Runs around $1-5 per month for casual gameplay.
Architecture + Core Components
At its heart, this project is just a set of AWS services working together:
- Amazon Bedrock Agents serve as the brain, handling the AI reasoning, memory management, and action orchestration.
-
Lambda Functions provide the serverless compute layer:
-
session-proxy
: Manages agent invocation and streams responses -
game-actions
: Handles character CRUD operations and session logging
-
- DynamoDB stores character sheets, game history, and session state with automatic scaling.
- API Gateway exposes a clean REST endpoint for game interactions.
- S3 Static Website Hosts the lightweight web client where players type their actions.
How it flows
- A player types something like: “I sneak past the guards.”
- API Gateway forwards that request to the Lambda session proxy.
- Lambda calls the Bedrock Agent. The agent either narrates directly or uses an action group (for example, checking a stealth score in DynamoDB).
- The DM’s response streams back to the client.
No servers to manage, no overworked innkeeper behind the console. Just a clean, serverless system.
Key Features That Make It Work
Smart Memory Management
The system maintains context across sessions, remembering:
- Character progression and stats
- Inventory changes
- Story decisions and consequences
- Campaign history and world state
Adaptive Difficulty
The AI DM adjusts challenges based on characters level and past performance.
Extensible Game Mechanics
Want to add dice rolling, combat systems, or custom rules? The modular Lambda architecture makes it easy to extend functionality.
Getting Started
Prerequisites
- Node.js 20+ and AWS CDK v2
- AWS Account with Amazon Bedrock access enabled
- Bedrock Model Access: Ensure your account has access to your chosen foundation model
- AWS CLI configured with appropriate permissions
- Python 3.8+ (for local development server) - you can also use the VS Code Extension - Live Server
The deployment process is simple:
# Clone and install
git clone https://github.com/chaotictoejam/serverless-dungeon-master
cd serverless-dungeon-master/dm-agent
npm install
# Deploy with CDK
npx cdk deploy
# Configure and play (optional)
cd ../web
python serve.py
That's it. The AI DM is ready to run its first campaign.
Try It Yourself
The complete source code is available here, including:
- Full CDK infrastructure code
- Lambda function implementations
- Web client for testing
- Detailed setup instructions
Whether you're a developer interested in AI applications, a gamer looking for consistent campaigns, or someone curious about serverless architecture, this project demonstrates how modern cloud services can solve real-world problems in creative ways.
The future of tabletop gaming might just be serverless.
Technical Deep Dive
For those interested in the implementation details, here's how the CDK infrastructure brings this AI DM to life:
CDK Stack Architecture
The entire infrastructure is defined in a single CDK stack that orchestrates five key components:
export class DmAgentStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Configuration with flexible model selection
const fmId = this.node.tryGetContext("fmId") ||
"us.anthropic.claude-3-7-sonnet-20250219-v1:0";
const agentName = this.node.tryGetContext("agentName") || "DungeonMaster";
DynamoDB: Game State Persistence
The game state table uses a composite key design for efficient player/session queries:
const table = new dynamodb.Table(this, "DmGameState", {
tableName: "dmGameState",
partitionKey: { name: "playerId", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sessionId", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
});
This design allows:
- Multi-session support per player
- Efficient queries by player or session
- Automatic scaling with pay-per-request billing
- Data protection with point-in-time recovery
Bedrock Agent: The AI Brain
The Bedrock Agent configuration defines the DM's personality and capabilities:
const agent = new bedrock.CfnAgent(this, "DmAgent", {
agentName,
foundationModel: fmId,
agentResourceRoleArn: agentServiceRole.roleArn,
instruction: [
"You are an AI Dungeon Master. Run safe, imaginative adventures.",
"Style: concise narration + clear choices. Never reveal tools or raw JSON.",
"When you need to read or persist game state, call the GameActions tools.",
"Default to PG-13 content; avoid explicit or unsafe material.",
].join(" "),
idleSessionTtlInSeconds: 900,
autoPrepare: true,
Action Groups: Game Mechanics Integration
The agent connects to game mechanics through a structured action group:
actionGroups: [{
actionGroupName: "GameActions",
description: "Game state operations (DynamoDB-backed)",
actionGroupExecutor: { lambda: gameActionsFn.functionArn },
functionSchema: {
functions: [
{
name: "get_character",
description: "Fetch a player character by playerId + sessionId",
parameters: {
playerId: { type: "string", description: "Player identifier", required: true },
sessionId: { type: "string", description: "Session identifier", required: true },
},
},
{
name: "save_character",
description: "Save/replace the player character",
parameters: {
playerId: { type: "string", required: true },
sessionId: { type: "string", required: true },
character: { type: "string", description: "Character data as JSON", required: true },
},
},
{
name: "append_log",
description: "Append a narrative log entry to world state",
parameters: {
playerId: { type: "string", required: true },
sessionId: { type: "string", required: true },
entry: { type: "string", description: "Log entry text", required: true },
},
},
],
},
}],
Lambda Functions: Serverless Compute
Session Proxy Lambda
Handles agent invocation and streams responses:
const sessionProxyFn = new lambdaNode.NodejsFunction(this, "SessionProxyFn", {
entry: "./lambda/session-proxy/index.ts",
runtime: lambda.Runtime.NODEJS_20_X,
timeout: cdk.Duration.seconds(60),
environment: {
AGENT_ID: agent.attrAgentId,
ALIAS_ID: alias.attrAgentAliasId,
},
});
The session proxy implementation handles streaming responses:
const cmd = new InvokeAgentCommand({
agentId: AGENT_ID,
agentAliasId: ALIAS_ID,
sessionId,
inputText: contextualInput
});
const response = await bedrockAgent.send(cmd);
let text = '';
for await (const chunkEvent of response.completion) {
const chunk = chunkEvent.chunk;
const decodedResponse = new TextDecoder("utf-8").decode(chunk?.bytes);
text += decodedResponse;
}
Game Actions Lambda
Implements the core game mechanics with DynamoDB operations:
if (fn === "save_character") {
const { playerId, sessionId, character } = params;
await ddb.send(
new UpdateCommand({
TableName: TABLE,
Key: { playerId, sessionId },
UpdateExpression: "SET playerCharacter = :c, lastUpdated = :t",
ExpressionAttributeValues: { ":c": character, ":t": Date.now() },
})
);
return ok(fn, { status: "saved" });
}
API Gateway: HTTP Interface
The API Gateway configuration enables CORS and connects to the session proxy:
const api = new apigwv2.HttpApi(this, "DmApi", {
apiName: "dm-agent-api",
corsPreflight: {
allowOrigins: ['*'],
allowMethods: [apigwv2.CorsHttpMethod.POST, apigwv2.CorsHttpMethod.OPTIONS],
allowHeaders: ['Content-Type', 'Accept']
}
});
api.addRoutes({
path: "/play",
methods: [apigwv2.HttpMethod.POST],
integration: new integrations.HttpLambdaIntegration("PlayIntegration", sessionProxyFn),
});
Security & IAM Configuration
The stack implements least-privilege access with specific IAM roles:
const agentServiceRole = new iam.Role(this, "AgentServiceRole", {
assumedBy: new iam.ServicePrincipal("bedrock.amazonaws.com", {
conditions: { StringEquals: { "aws:SourceAccount": cdk.Aws.ACCOUNT_ID } },
}),
});
agentServiceRole.addToPolicy(
new iam.PolicyStatement({
actions: [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:GetFoundationModel",
],
resources: ["*"],
})
);
Deployment Outputs
The stack provides essential outputs for client configuration:
new cdk.CfnOutput(this, "ApiUrl", { value: api.apiEndpoint! });
new cdk.CfnOutput(this, "AgentId", { value: agent.attrAgentId });
new cdk.CfnOutput(this, "AgentAliasId", { value: alias.attrAgentAliasId });
Key Design Decisions
Single-Table DynamoDB Design: Uses composite keys (playerId + sessionId) for efficient queries while supporting multiple concurrent sessions per player.
Streaming Responses: The session proxy processes Bedrock's streaming responses to provide real-time feedback, crucial for maintaining immersion.
Modular Lambda Architecture: Separates concerns between session management and game actions, enabling independent scaling and easier maintenance.
Pay-Per-Request Billing: All services use on-demand pricing, perfect for unpredictable gaming workloads.
Auto-Prepare Agents: Bedrock agents automatically prepare when deployed, reducing cold-start latency.
Lambda Function Deep Dive
Let's examine the Lambda functions that power the game mechanics and session management:
Session Proxy Lambda: The Gateway
The session proxy acts as the bridge between API Gateway and Bedrock Agent:
import { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { BedrockAgentRuntimeClient, InvokeAgentCommand } from '@aws-sdk/client-bedrock-agent-runtime';
const bedrockAgent = new BedrockAgentRuntimeClient({ region: "us-east-1" });
const AGENT_ID = process.env.AGENT_ID!;
const ALIAS_ID = process.env.ALIAS_ID!;
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const corsHeaders = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST,OPTIONS,GET",
"Access-Control-Allow-Headers": "Content-Type, Accept",
};
Request Processing
The function validates required parameters and enriches the input with context:
const body = event.body ? JSON.parse(event.body) : {};
const { sessionId, playerId, inputText } = body;
if (!sessionId || !playerId || !inputText) {
return {
statusCode: 400,
headers: corsHeaders,
body: JSON.stringify({
error: 'Missing required parameters',
missing: [!sessionId && 'sessionId', !playerId && 'playerId', !inputText && 'inputText'].filter(Boolean)
})
};
}
const contextualInput = `Player ID: ${playerId}, Session ID: ${sessionId}\n\n${inputText}`;
Streaming Response Handling
The core streaming logic processes Bedrock's chunked responses:
const cmd = new InvokeAgentCommand({
agentId: AGENT_ID,
agentAliasId: ALIAS_ID,
sessionId,
inputText: contextualInput
});
const response = await bedrockAgent.send(cmd);
let text = '';
for await (const chunkEvent of response.completion) {
const chunk = chunkEvent.chunk;
const decodedResponse = new TextDecoder("utf-8").decode(chunk?.bytes);
text += decodedResponse;
}
return {
statusCode: 200,
headers: corsHeaders,
body: JSON.stringify({ reply: text })
};
Key Features:
- CORS handling for web client compatibility
- Input validation with detailed error responses
- Context enrichment to provide player/session info to the agent
- Streaming aggregation for complete response assembly
- Error handling with structured error responses
Game Actions Lambda: The Persistence Layer
The game actions function implements the core CRUD operations for character and world state:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE!;
type AgentParam = { name?: string; key?: string; value: any };
type AgentEvent = {
actionGroup: string;
function: string;
parameters?: AgentParam[];
};
Parameter Processing
The function normalizes Bedrock Agent's parameter format:
function toParamMap(params?: AgentParam[]) {
const p = params || [];
const entries = p.map((pp) => [pp.name ?? pp.key, pp.value]);
return Object.fromEntries(entries);
}
function ok(functionName: string, body: any) {
return {
messageVersion: "1.0",
response: {
actionGroup: "GameActions",
function: functionName,
functionResponse: {
responseBody: {
TEXT: {
body: JSON.stringify(body),
},
},
},
},
};
}
Character Management
The get_character function retrieves player data:
if (fn === "get_character") {
const { playerId, sessionId } = params;
const res = await ddb.send(
new GetCommand({
TableName: TABLE,
Key: { playerId, sessionId },
})
);
const character = res.Item?.playerCharacter ? JSON.parse(res.Item.playerCharacter) : null;
return ok(fn, {
character: character,
world: res.Item?.world ?? null,
});
}
The save_character function persists character updates:
if (fn === "save_character") {
const { playerId, sessionId, character } = params;
await ddb.send(
new UpdateCommand({
TableName: TABLE,
Key: { playerId, sessionId },
UpdateExpression: "SET playerCharacter = :c, lastUpdated = :t",
ExpressionAttributeValues: { ":c": character, ":t": Date.now() },
})
);
return ok(fn, { status: "saved" });
}
World State Logging
The append_log function maintains narrative history:
if (fn === "append_log") {
const { playerId, sessionId, entry } = params;
await ddb.send(
new UpdateCommand({
TableName: TABLE,
Key: { playerId, sessionId },
UpdateExpression:
"SET #world = if_not_exists(#world, :emptyWorld), #world.#logs = list_append(if_not_exists(#world.#logs, :empty), :e), lastUpdated = :t",
ExpressionAttributeNames: { "#world": "world", "#logs": "logs" },
ExpressionAttributeValues: {
":e": [entry],
":empty": [],
":emptyWorld": {},
":t": Date.now(),
},
})
);
return ok(fn, { status: "logged" });
}
Key Features:
- Flexible parameter handling for Bedrock Agent compatibility
- Atomic updates using DynamoDB UpdateExpressions
- Conditional operations with if_not_exists for safe initialization
- List operations for appending log entries
- Structured responses matching Bedrock Agent expectations
- Error handling with graceful fallbacks
Lambda Architecture Benefits
Separation of Concerns: The session proxy handles HTTP/streaming while game actions focus purely on data operations.
Independent Scaling: Each function scales based on its specific workload patterns.
Error Isolation: Failures in one function don't affect the other's operation.
Testability: Pure functions with clear inputs/outputs enable comprehensive testing.
Cost Efficiency: Functions only run when needed, with sub-second billing granularity.
This modular approach demonstrates how serverless functions can create clean, maintainable architectures for complex AI applications.
Development Challenges & Solutions
Building this AI DM wasn't without its hurdles. Here are the key challenges encountered and how they were resolved:
Bedrock Model Access Issues
Problem: Initial deployment failed with "AccessDeniedException" when invoking the foundation model.
Root Cause: Bedrock model access isn't automatically enabled for all AWS accounts. Each foundation model requires an explicit access request.
Solution:
- Navigate to AWS Bedrock Console → Model Access
- Request access to Claude 3.5 Sonnet (or your chosen model)
- Wait for approval (usually instant for standard models)
- Update CDK configuration with the correct model ID:
const fmId = "us.anthropic.claude-3-7-sonnet-20250219-v1:0"; // Verify exact model ID
Lesson: Always verify model availability in your target region before deployment.
CORS Configuration Nightmare
Problem: Web client requests failing with CORS errors despite API Gateway CORS configuration.
Root Cause: CORS headers needed in both API Gateway AND Lambda responses for proper browser compatibility.
Solution: Implement CORS at multiple layers:
// API Gateway CORS (CDK)
corsPreflight: {
allowOrigins: ['*'],
allowMethods: [apigwv2.CorsHttpMethod.POST, apigwv2.CorsHttpMethod.OPTIONS],
allowHeaders: ['Content-Type', 'Accept']
}
// Lambda CORS headers (every response)
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST,OPTIONS,GET",
"Access-Control-Allow-Headers": "Content-Type, Accept",
};
// Handle OPTIONS preflight
if (event.requestContext?.http?.method === 'OPTIONS') {
return { statusCode: 200, headers: corsHeaders, body: '' };
}
Lesson: CORS requires configuration at both the gateway and function levels for web clients.
Lambda Timeout Mysteries
Problem: Bedrock Agent calls timing out with the default 3-second Lambda timeout.
Root Cause: AI model inference can take 10-30 seconds, especially for complex reasoning tasks.
Solution: Increase Lambda timeout and add proper error handling:
const sessionProxyFn = new lambdaNode.NodejsFunction(this, "SessionProxyFn", {
timeout: cdk.Duration.seconds(60), // Increased from default 3s
// ... other config
});
Lesson: AI workloads require generous timeout configurations.
Bedrock Agent Parameter Confusion
Problem: Game actions Lambda receiving inconsistent parameter formats from Bedrock Agent.
Root Cause: Bedrock Agent can send parameters with either name
or key
properties depending on the invocation context.
Solution: Flexible parameter parsing:
function toParamMap(params?: AgentParam[]) {
const p = params || [];
const entries = p.map((pp) => [pp.name ?? pp.key, pp.value]);
return Object.fromEntries(entries);
}
Lesson: Always handle parameter format variations in Bedrock Agent integrations.
DynamoDB Conditional Update Failures
Problem: append_log function failing when trying to append to non-existent lists.
Root Cause: DynamoDB list_append requires the list to exist, but new sessions don't have world.logs initialized.
Solution: Use conditional expressions with if_not_exists:
UpdateExpression: "SET #world = if_not_exists(#world, :emptyWorld), #world.#logs = list_append(if_not_exists(#world.#logs, :empty), :e)"
Lesson: DynamoDB conditional operations are essential for handling uninitialized data structures.
Agent Preparation Race Conditions
Problem: Bedrock Agent not ready immediately after CDK deployment, causing initial invocation failures.
Root Cause: Agent preparation is asynchronous, even with autoPrepare: true
.
Solution: Add retry logic and use agent aliases:
const alias = new bedrock.CfnAgentAlias(this, "DmAgentAlias", {
agentId: agent.attrAgentId,
agentAliasName: "prod",
});
// Use alias ID instead of agent ID for invocations
environment: {
AGENT_ID: agent.attrAgentId,
ALIAS_ID: alias.attrAgentAliasId, // More stable than direct agent calls
}
Lesson: Always use agent aliases for production workloads to ensure stability.
Streaming Response Edge Cases
Problem: Occasional empty responses or incomplete streaming from Bedrock Agent.
Root Cause: Network issues or agent processing errors not properly handled.
Solution: Add comprehensive error handling and fallbacks:
if (response.completion === undefined) {
throw new Error("BedRock Agent completion is undefined");
}
for await (const chunkEvent of response.completion) {
const chunk = chunkEvent.chunk;
if (chunk?.bytes) {
const decodedResponse = new TextDecoder("utf-8").decode(chunk.bytes);
text += decodedResponse;
}
}
if (!text) {
text = 'No response from agent'; // Fallback for empty responses
}
Lesson: Always validate streaming responses and provide fallbacks for edge cases.
Regional Service Availability
Problem: Bedrock services are not available in all AWS regions, causing deployment failures.
Root Cause: Bedrock has limited regional availability compared to core AWS services.
Solution: Hardcode region or validate availability:
const bedrockAgent = new BedrockAgentRuntimeClient({ region: "us-east-1" }); // Bedrock-supported region
Lesson: Check service regional availability before choosing deployment regions.
Debugging Best Practices
CloudWatch Logs: Essential for debugging Lambda and Bedrock Agent interactions:
console.log('Invoking agent with:', { agentId: AGENT_ID, sessionId, inputText });
console.log('Agent response received:', { hasCompletion: !!response.completion });
Structured Error Responses: Help identify issues quickly:
catch (err: any) {
console.error('Agent invocation error:', {
message: err.message,
code: err.code || err.name,
agentId: AGENT_ID,
aliasId: ALIAS_ID
});
return {
statusCode: 500,
body: JSON.stringify({
error: 'Agent invocation failed',
message: err.message,
code: err.code || 'UnknownError'
})
};
}
Testing Strategy: Start with simple inputs before complex game scenarios:
- Test basic agent invocation with "Hello"
- Verify parameter passing with simple character data
- Test streaming with longer responses
- Validate error handling with invalid inputs
These challenges highlight the importance of thorough testing, proper error handling, and understanding the nuances of AI service integrations in serverless architectures.
Next Steps: Migration to Amazon Bedrock Agent Core
While the current Bedrock Agents implementation serves as a decent POC, Amazon Bedrock Agent Core offers a more sophisticated framework for building production-grade AI agents.
Why Migrate to Bedrock Agent Core?
- Enhanced Control Flow: Agent Core provides fine-grained control over reasoning chains, tool orchestration, and decision-making processes compared to standard Bedrock Agents.
- Advanced Memory Management: Built-in support for long-term memory, context compression, and intelligent information retrieval across extended gaming sessions.
- Multi-Agent Orchestration: Native support for multiple specialized agents (combat manager, story narrator, rule enforcer) working together seamlessly.
- Performance Optimization: Reduced latency through optimized model routing, caching strategies, and parallel processing capabilities.
- Enterprise Features: Enhanced monitoring, audit trails, and compliance features essential for production deployments.
Conclusion
Building this Serverless Dungeon Master Agent began as a “what if” idea and evolved into a working and somewhat scalable prototype. By combining Amazon Bedrock with serverless building blocks, I demonstrated how to create an AI-powered DM that remembers, adapts, and entertains—while remaining cost-effective.
Whether you use it as a foundation for your own gaming experiments or as inspiration for other AI-driven applications, the takeaway is clear: serverless architectures make it possible to turn imaginative ideas into practical, low-maintenance solutions.
The future of gaming, and beyond, might just be serverless.
Top comments (0)