Eu sempre quis construir algo que misturasse IA generativa com jogos. Não um chatbot genérico, mas uma experiência onde a IA fosse o Dungeon Master — narrando histórias, gerando imagens das cenas, controlando combate, progressão e economia do jogo.
O resultado é o DungeonAI: um RPG text adventure onde o jogador escolhe ações (ou digita livremente), e a IA responde com narrativa em português, uma imagem gerada da cena, e atualizações de estado — HP, ouro, XP, inventário, localização. Tudo persiste no DynamoDB, as imagens ficam no S3, e a orquestração usa Strands Agents, o framework open-source da AWS para agentes de IA.
Neste post eu documento a arquitetura completa, cada arquivo de código, e o passo a passo para rodar na sua conta AWS. O código está no GitHub: github.com/cloudbymcn/dungeonai.
@tool, conectar a modelos do Bedrock, e orquestrar fluxos complexos. Pense em “LangChain, mas mais simples e integrado com AWS”.
Arquitetura
O fluxo geral do DungeonAI é este:
Jogador → Streamlit → Strands Agent → Amazon Bedrock
|
Claude 3.5 Haiku / Titan Image V2 / DynamoDB
|
S3 Bucket
A cada turno, o ciclo completo funciona assim:
- Jogador escolhe uma ação ou digita livremente no Streamlit
- Strands Agent carrega o estado atual do DynamoDB (HP, inventário, localização, histórico)
- Claude 3.5 Haiku recebe o contexto + ação e gera: narrativa, prompt de imagem, mudanças de estado e opções para o próximo turno
- Titan Image Generator V2 gera a imagem da cena a partir do prompt
- Imagem é salva no S3, estado atualizado no DynamoDB
- Frontend renderiza narrativa + imagem + status atualizado
Cada serviço tem uma responsabilidade clara: Bedrock fornece os modelos, DynamoDB persiste estado entre sessões, S3 armazena as imagens geradas, e Strands Agents orquestra tudo com tools tipadas. Não tem servidor para gerenciar — é tudo pay-per-use.
Serviços AWS Utilizados
Para quem não é familiar com AWS, aqui vai o papel de cada serviço e o custo estimado por sessão de jogo (~30 turnos):
| Serviço | O que faz neste projeto | Custo/sessão |
|---|---|---|
| Amazon Bedrock (Claude 3.5 Haiku) | Gera a narrativa, decide combate, cria prompts de imagem, atualiza estado do jogo. Pense em “o cérebro do Dungeon Master”. | ~$0.02 |
| Amazon Bedrock (Titan Image Generator V2) | Gera imagens das cenas a partir de prompts em inglês. Cada turno gera uma imagem. Pense em “o ilustrador da aventura”. | ~$0.40–0.80 |
| DynamoDB | Armazena o estado do jogo (HP, ouro, XP, inventário, localização, histórico de turnos). Persistência entre sessões. Pense em “a ficha do personagem na nuvem”. | <$0.01 |
| S3 | Armazena as imagens geradas por Titan. Cada imagem ~150KB. Pense em “o álbum de fotos da aventura”. | <$0.01 |
Estrutura do Projeto
dungeonai/
├── .env.example
├── .gitignore
├── requirements.txt
├── agent/
│ ├── dungeon_master.py # orquestrador principal
│ ├── prompts/
│ │ └── system_prompt.txt # personalidade do DM
│ └── tools/
│ ├── narrate_story.py # chama Claude Haiku
│ ├── generate_scene.py # chama Titan Image
│ └── update_state.py # lê/grava DynamoDB
└── frontend/
└── app.py # Streamlit dark fantasy theme
Passo a Passo
Configurar modelos no Amazon Bedrock
No console da AWS, eu fui em Amazon Bedrock → Model Catalog (região us-east-1) e habilitei dois modelos:
- Claude 3.5 Haiku (Anthropic) — para geração de texto/narrativa
- Titan Image Generator V2 (Amazon) — para geração de imagens
Ambos ficam disponíveis em segundos. Não precisa provisionar nada — só habilitar o acesso.
us-east-1. Nem todos os modelos estão disponíveis em todas as regiões.
Criar tabela no DynamoDB
Eu criei uma tabela chamada dungeonai-game-sessions com partition key session_id (String) e modo On-Demand (paga por request, sem provisionar capacidade).
- AWS Console → DynamoDB → Create table
- Table name:
dungeonai-game-sessions - Partition key:
session_id(String) - Capacity mode: On-demand
Criar bucket no S3
Criei um bucket chamado dungeonai-scenes para armazenar as imagens geradas pelo Titan. Mantive o Block Public Access ativado — as imagens são acessadas via SDK, não via URL pública.
- AWS Console → S3 → Create bucket
- Bucket name:
dungeonai-scenes(deve ser único globalmente — ajuste o nome) - Region:
us-east-1 - Block Public Access: ativado
Configurar credenciais AWS
No terminal, eu rodei aws configure para definir as credenciais do meu usuário IAM. Depois criei uma policy com as permissões necessárias:
dungeonai-policy.json (clique para expandir)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BedrockAccess",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:Converse"
],
"Resource": [
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-haiku-20241022-v1:0",
"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-image-generator-v2:0"
]
},
{
"Sid": "DynamoDBAccess",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/dungeonai-game-sessions"
},
{
"Sid": "S3Access",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": "arn:aws:s3:::dungeonai-scenes/*"
}
]
}
aws configure ou variáveis de ambiente. O .env está no .gitignore.
Criar projeto e instalar dependências
Eu criei a estrutura de pastas e os arquivos de configuração:
requirements.txt (clique para expandir)
strands-agents[bedrock]
boto3>=1.35.0
streamlit>=1.40.0
python-dotenv>=1.0.0
Pillow>=10.0.0
.env.example (clique para expandir)
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
DYNAMODB_TABLE=dungeonai-game-sessions
S3_BUCKET=dungeonai-scenes
BEDROCK_TEXT_MODEL=us.anthropic.claude-3-5-haiku-20241022-v1:0
BEDROCK_IMAGE_MODEL=amazon.titan-image-generator-v2:0
.gitignore (clique para expandir)
.env
__pycache__/
*.pyc
.venv/
venv/
.streamlit/
Para instalar:
python -m venv .venv
source .venv/bin/activate # no Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# edite .env com suas credenciais reais
Criar o System Prompt
Este é o arquivo mais importante do projeto. Ele define a personalidade do Dungeon Master, as regras de combate, o mundo do jogo, e o formato de saída JSON que o Claude deve seguir. Eu iterei bastante nesse prompt até chegar num resultado consistente.
agent/prompts/system_prompt.txt (clique para expandir)
You are the Dungeon Master of "Caverns of Eldrath", a dark fantasy text adventure RPG.
You narrate in Brazilian Portuguese (pt-BR), with vivid and immersive descriptions.
═══ NARRATIVE RULES ═══
- Always narrate in 2nd person ("Voce entra na caverna...")
- Keep narratives between 3-6 paragraphs
- Include sensory details: sounds, smells, light, temperature
- Reference the player's current state (HP, inventory, location) naturally
- End each turn with 2-4 action options for the player
- If the player types a free action, interpret it creatively within the story
═══ TONE AND PERSONALITY ═══
- Dark fantasy with moments of humor
- Mysterious and atmospheric, but never frustrating
- Reward creative actions with bonus XP or hidden items
- Be fair in combat — the player should feel challenged but not cheated
- Use dramatic pauses and cliffhangers between scenes
═══ COMBAT SYSTEM (d20) ═══
- Attack rolls: random 1-20. Hit if roll + player_level >= enemy_ac
- Critical hit on natural 20: double damage
- Critical fail on natural 1: player takes self-damage (1-3 HP)
- Player base damage: 3-8 + weapon_bonus
- Enemy damage varies by creature type
- Player can flee combat (50% chance, costs 1-3 HP on failure)
- Healing potions restore 15-25 HP
═══ PROGRESSION AND ECONOMY ═══
- XP per combat victory: 10-30 (based on enemy difficulty)
- XP per puzzle solved: 15-25
- XP per exploration milestone: 5-10
- Level up at: 100, 250, 500, 800, 1200 XP
- Level up grants: +10 max HP, +1 base damage
- Gold drops: 5-20 per enemy, 10-50 in treasure chests
- Shop prices: Healing Potion (30g), Torch (10g), Rope (15g),
Dagger+1 (80g), Shield (60g), Magic Scroll (100g)
═══ GAME WORLD: CAVERNS OF ELDRATH ═══
Locations:
1. Entrance Hall — dimly lit, cobblestone floor, merchant NPC
2. Fungal Caverns — bioluminescent mushrooms, poisonous spores
3. Underground River — dark water, stepping stones, water creatures
4. Dwarven Ruins — ancient forges, traps, valuable loot
5. Shadow Depths — near-total darkness, powerful enemies
6. Dragon's Sanctum — final area, boss fight
Creatures:
- Cave Rats (HP:8, AC:8, DMG:1-3, XP:10) — Entrance Hall
- Mushroom Golem (HP:25, AC:12, DMG:4-8, XP:20) — Fungal Caverns
- River Serpent (HP:30, AC:13, DMG:5-10, XP:25) — Underground River
- Dwarven Automaton (HP:40, AC:15, DMG:6-12, XP:30) — Dwarven Ruins
- Shadow Wraith (HP:50, AC:16, DMG:8-15, XP:40) — Shadow Depths
NPCs:
- Thorin the Merchant (Entrance Hall): buys/sells items, gives hints
- Elara the Ghost (Dwarven Ruins): gives quest to find dwarven artifact
═══ IMAGE PROMPT RULES ═══
- Always generate an image prompt in ENGLISH
- Style: dark fantasy digital painting, atmospheric lighting
- Include: the scene described, the environment, any creatures present
- Do NOT include text, UI elements, or player character in the image
- Keep prompts under 200 characters for best Titan results
- Format: "Dark fantasy scene: [description], atmospheric lighting, detailed"
═══ OUTPUT FORMAT ═══
You MUST respond with valid JSON in this exact structure:
{
"narrative": "A narrativa em portugues...",
"image_prompt": "Dark fantasy scene: description in English...",
"state_changes": {
"hp_change": 0,
"gold_change": 0,
"xp_change": 0,
"add_items": [],
"remove_items": [],
"new_location": null
},
"options": [
"Opcao 1 em portugues",
"Opcao 2 em portugues",
"Opcao 3 em portugues"
],
"combat_log": null
}
If combat occurs, combat_log should be:
{
"enemy_name": "Name",
"enemy_hp": 0,
"roll": 0,
"hit": true/false,
"damage_dealt": 0,
"damage_taken": 0,
"combat_over": true/false,
"victory": true/false
}
CRITICAL: Always respond with ONLY the JSON. No markdown, no extra text.
O Claude Haiku é rápido e barato, mas precisa de instruções claras para manter consistência entre turnos. Sem as regras explícitas de combate (d20), ele inventava mecânicas diferentes a cada turno. Sem o formato JSON estrito, ele misturava narrativa com dados de estado. Cada seção desse prompt foi iterada até o comportamento ficar previsível.
Criar as Agent Tools
O Strands Agents usa o decorador @tool para transformar funções Python em ferramentas que o agente pode chamar. Eu criei três tools:
agent/tools/narrate_story.py (clique para expandir)
"""Tool para gerar narrativa usando Claude 3.5 Haiku via Bedrock."""
import json
import os
import boto3
from strands import tool
from dotenv import load_dotenv
load_dotenv()
bedrock = boto3.client("bedrock-runtime", region_name=os.getenv("AWS_REGION", "us-east-1"))
MODEL_ID = os.getenv("BEDROCK_TEXT_MODEL", "us.anthropic.claude-3-5-haiku-20241022-v1:0")
@tool
def narrate_story(
system_prompt: str,
player_action: str,
game_state: dict
) -> dict:
"""
Generates the next turn narrative, image prompt, state changes,
and player options using Claude 3.5 Haiku.
Args:
system_prompt: The DM system prompt with all game rules.
player_action: What the player chose to do this turn.
game_state: Current game state (hp, gold, xp, inventory, location, history).
Returns:
dict with keys: narrative, image_prompt, state_changes, options, combat_log.
"""
# Build context message with current state
context = (
f"Estado atual do jogador:\n"
f"- HP: {game_state.get('hp', 100)}/{game_state.get('max_hp', 100)}\n"
f"- Gold: {game_state.get('gold', 0)}\n"
f"- XP: {game_state.get('xp', 0)} (Level {game_state.get('level', 1)})\n"
f"- Inventario: {', '.join(game_state.get('inventory', [])) or 'vazio'}\n"
f"- Localizacao: {game_state.get('location', 'Entrance Hall')}\n"
f"- Turno: {game_state.get('turn', 1)}\n\n"
f"Historico recente:\n"
)
# Include last 3 turns of history for context
history = game_state.get("history", [])
for entry in history[-3:]:
context += f"- Jogador: {entry.get('action', '?')}\n"
context += f" Resultado: {entry.get('summary', '?')}\n"
context += f"\nAcao do jogador agora: {player_action}"
# Call Bedrock Converse API
response = bedrock.converse(
modelId=MODEL_ID,
system=[{"text": system_prompt}],
messages=[
{
"role": "user",
"content": [{"text": context}]
}
],
inferenceConfig={
"maxTokens": 2048,
"temperature": 0.8,
"topP": 0.9
}
)
# Extract response text
raw_text = response["output"]["message"]["content"][0]["text"]
# Parse JSON — with fallback for malformed responses
try:
# Try direct JSON parse
result = json.loads(raw_text)
except json.JSONDecodeError:
# Try extracting JSON from markdown code block
try:
json_start = raw_text.index("{")
json_end = raw_text.rindex("}") + 1
result = json.loads(raw_text[json_start:json_end])
except (ValueError, json.JSONDecodeError):
# Fallback: return raw text as narrative
result = {
"narrative": raw_text,
"image_prompt": "Dark fantasy scene: mysterious cavern with torchlight, atmospheric lighting, detailed",
"state_changes": {
"hp_change": 0,
"gold_change": 0,
"xp_change": 0,
"add_items": [],
"remove_items": [],
"new_location": None
},
"options": [
"Explorar a area",
"Verificar o inventario",
"Descansar"
],
"combat_log": None
}
return result
agent/tools/generate_scene.py (clique para expandir)
"""Tool para gerar imagens de cena usando Titan Image Generator V2 via Bedrock."""
import json
import os
import base64
import uuid
from datetime import datetime
import boto3
from strands import tool
from dotenv import load_dotenv
load_dotenv()
bedrock = boto3.client("bedrock-runtime", region_name=os.getenv("AWS_REGION", "us-east-1"))
s3 = boto3.client("s3", region_name=os.getenv("AWS_REGION", "us-east-1"))
MODEL_ID = os.getenv("BEDROCK_IMAGE_MODEL", "amazon.titan-image-generator-v2:0")
BUCKET = os.getenv("S3_BUCKET", "dungeonai-scenes")
@tool
def generate_scene(
image_prompt: str,
session_id: str,
turn_number: int
) -> dict:
"""
Generates a scene image using Titan Image Generator V2 and saves to S3.
Args:
image_prompt: English description of the scene to generate.
session_id: The game session ID for organizing images.
turn_number: Current turn number for the filename.
Returns:
dict with keys: image_base64, s3_key, success.
"""
try:
# Call Titan Image Generator V2
body = json.dumps({
"textToImageParams": {
"text": image_prompt
},
"imageGenerationConfig": {
"numberOfImages": 1,
"height": 512,
"width": 512,
"cfgScale": 8.0,
"seed": int(uuid.uuid4().int % 2147483647)
}
})
response = bedrock.invoke_model(
modelId=MODEL_ID,
contentType="application/json",
accept="application/json",
body=body
)
result = json.loads(response["body"].read())
image_base64 = result["images"][0]
# Save to S3
s3_key = f"sessions/{session_id}/turn_{turn_number:04d}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.png"
image_bytes = base64.b64decode(image_base64)
s3.put_object(
Bucket=BUCKET,
Key=s3_key,
Body=image_bytes,
ContentType="image/png",
Metadata={
"session_id": session_id,
"turn": str(turn_number),
"prompt": image_prompt[:256]
}
)
return {
"image_base64": image_base64,
"s3_key": s3_key,
"success": True
}
except Exception as e:
print(f"[generate_scene] Error: {e}")
return {
"image_base64": None,
"s3_key": None,
"success": False
}
agent/tools/update_state.py (clique para expandir)
"""Tool para gerenciar estado do jogo no DynamoDB."""
import os
import json
from datetime import datetime
from decimal import Decimal
import boto3
from strands import tool
from dotenv import load_dotenv
load_dotenv()
dynamodb = boto3.resource("dynamodb", region_name=os.getenv("AWS_REGION", "us-east-1"))
table = dynamodb.Table(os.getenv("DYNAMODB_TABLE", "dungeonai-game-sessions"))
# XP thresholds for leveling up
LEVEL_THRESHOLDS = [0, 100, 250, 500, 800, 1200]
def _decimal_default(obj):
"""JSON serializer for Decimal objects from DynamoDB."""
if isinstance(obj, Decimal):
return int(obj) if obj == int(obj) else float(obj)
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
def _to_decimal(data):
"""Convert floats/ints to Decimal for DynamoDB compatibility."""
if isinstance(data, dict):
return {k: _to_decimal(v) for k, v in data.items()}
elif isinstance(data, list):
return [_to_decimal(i) for i in data]
elif isinstance(data, float):
return Decimal(str(data))
elif isinstance(data, int):
return Decimal(str(data))
return data
@tool
def get_game_state(session_id: str) -> dict:
"""
Loads the current game state from DynamoDB.
Args:
session_id: The unique session identifier.
Returns:
dict with the full game state, or a fresh state if session is new.
"""
response = table.get_item(Key={"session_id": session_id})
if "Item" in response:
# Convert Decimal back to native types
item = json.loads(json.dumps(response["Item"], default=_decimal_default))
return item
# New session — return default state
return {
"session_id": session_id,
"hp": 100,
"max_hp": 100,
"gold": 20,
"xp": 0,
"level": 1,
"inventory": ["Torch", "Healing Potion"],
"location": "Entrance Hall",
"turn": 0,
"history": [],
"created_at": datetime.utcnow().isoformat(),
"updated_at": datetime.utcnow().isoformat(),
"alive": True
}
@tool
def save_game_state(
session_id: str,
current_state: dict,
state_changes: dict,
player_action: str,
narrative_summary: str
) -> dict:
"""
Applies state changes and saves the updated game state to DynamoDB.
Args:
session_id: The unique session identifier.
current_state: The current game state before changes.
state_changes: Changes to apply (hp_change, gold_change, xp_change, etc).
player_action: What the player did this turn (for history).
narrative_summary: Short summary of what happened (for history).
Returns:
dict with the updated game state.
"""
state = current_state.copy()
# Apply HP change
hp_change = state_changes.get("hp_change", 0)
state["hp"] = max(0, min(state["max_hp"], state["hp"] + hp_change))
# Apply gold change
gold_change = state_changes.get("gold_change", 0)
state["gold"] = max(0, state["gold"] + gold_change)
# Apply XP change and check level up
xp_change = state_changes.get("xp_change", 0)
state["xp"] = state["xp"] + xp_change
new_level = _calculate_level(state["xp"])
if new_level > state["level"]:
levels_gained = new_level - state["level"]
state["level"] = new_level
state["max_hp"] = state["max_hp"] + (10 * levels_gained)
state["hp"] = state["max_hp"] # Full heal on level up
# Apply inventory changes
add_items = state_changes.get("add_items", [])
remove_items = state_changes.get("remove_items", [])
for item in add_items:
if item not in state["inventory"]:
state["inventory"].append(item)
for item in remove_items:
if item in state["inventory"]:
state["inventory"].remove(item)
# Apply location change
new_location = state_changes.get("new_location")
if new_location:
state["location"] = new_location
# Update turn counter
state["turn"] = state.get("turn", 0) + 1
# Add to history (keep last 20 turns)
history_entry = {
"turn": state["turn"],
"action": player_action,
"summary": narrative_summary[:200],
"hp_after": state["hp"],
"location": state["location"],
"timestamp": datetime.utcnow().isoformat()
}
state["history"] = state.get("history", [])[-19:] + [history_entry]
# Check death
if state["hp"] <= 0:
state["alive"] = False
# Update timestamp
state["updated_at"] = datetime.utcnow().isoformat()
# Save to DynamoDB
state["session_id"] = session_id
table.put_item(Item=_to_decimal(state))
return state
def _calculate_level(xp: int) -> int:
"""Calculate player level based on XP thresholds."""
level = 1
for i, threshold in enumerate(LEVEL_THRESHOLDS):
if xp >= threshold:
level = i + 1
return min(level, len(LEVEL_THRESHOLDS))
Decimal? O DynamoDB não aceita float do Python. Todos os números precisam ser convertidos para Decimal antes de gravar e reconvertidos ao ler. Esse detalhe causa muitos bugs se você não tratar.
Criar o Orquestrador
O dungeon_master.py é o cérebro do projeto. Ele cria o agente Strands com o modelo Bedrock, carrega o system prompt, e coordena o fluxo de cada turno chamando as tools na ordem certa.
agent/dungeon_master.py (clique para expandir)
"""Orquestrador do DungeonAI — coordena tools e fluxo de cada turno."""
import os
import uuid
from pathlib import Path
from strands import Agent
from strands.models.bedrock import BedrockModel
from dotenv import load_dotenv
from agent.tools.narrate_story import narrate_story
from agent.tools.generate_scene import generate_scene
from agent.tools.update_state import get_game_state, save_game_state
load_dotenv()
# Load system prompt
PROMPT_PATH = Path(__file__).parent / "prompts" / "system_prompt.txt"
SYSTEM_PROMPT = PROMPT_PATH.read_text(encoding="utf-8")
def create_agent() -> Agent:
"""Create and configure the Strands Agent with Bedrock model and tools."""
model = BedrockModel(
model_id=os.getenv("BEDROCK_TEXT_MODEL", "us.anthropic.claude-3-5-haiku-20241022-v1:0"),
region_name=os.getenv("AWS_REGION", "us-east-1")
)
agent = Agent(
model=model,
tools=[narrate_story, generate_scene, get_game_state, save_game_state]
)
return agent
def new_session() -> str:
"""Generate a new unique session ID."""
return str(uuid.uuid4())
def play_turn(session_id: str, player_action: str) -> dict:
"""
Execute one complete game turn.
Flow:
1. Load state from DynamoDB
2. Generate narrative + state changes with Claude Haiku
3. Generate scene image with Titan
4. Save updated state to DynamoDB
5. Return everything to frontend
Args:
session_id: The game session ID.
player_action: What the player chose to do.
Returns:
dict with: narrative, image_base64, options, game_state, combat_log.
"""
# Step 1: Load current state
game_state = get_game_state(session_id=session_id)
# Check if player is dead
if not game_state.get("alive", True):
return {
"narrative": "Voce tombou nas Cavernas de Eldrath. Sua jornada terminou aqui.",
"image_base64": None,
"options": ["Iniciar nova aventura"],
"game_state": game_state,
"combat_log": None,
"game_over": True
}
# Step 2: Generate narrative with Claude Haiku
turn_result = narrate_story(
system_prompt=SYSTEM_PROMPT,
player_action=player_action,
game_state=game_state
)
# Step 3: Generate scene image with Titan
image_result = generate_scene(
image_prompt=turn_result.get("image_prompt", "Dark fantasy cavern scene"),
session_id=session_id,
turn_number=game_state.get("turn", 0) + 1
)
# Step 4: Save updated state to DynamoDB
updated_state = save_game_state(
session_id=session_id,
current_state=game_state,
state_changes=turn_result.get("state_changes", {}),
player_action=player_action,
narrative_summary=turn_result.get("narrative", "")[:200]
)
# Step 5: Return everything to frontend
return {
"narrative": turn_result.get("narrative", ""),
"image_base64": image_result.get("image_base64"),
"options": turn_result.get("options", ["Explorar", "Descansar", "Verificar inventario"]),
"game_state": updated_state,
"combat_log": turn_result.get("combat_log"),
"game_over": not updated_state.get("alive", True)
}
Strands é o framework open-source da própria AWS. A integração com Bedrock é nativa (sem adapters), o decorador @tool é mais simples que o tooling do LangChain, e o overhead é menor. Para um projeto 100% AWS, faz mais sentido.
Três motivos: (1) é rápido — responde em ~1s, essencial para um jogo interativo; (2) é barato — ~$0.0008/turno; (3) segue instruções de formato JSON de forma confiável, o que nem todo modelo pequeno faz bem. E o português é excelente.
Criar o Frontend
O frontend usa Streamlit com um tema dark fantasy customizado. Aqui eu mostro as partes principais — o CSS completo tem ~350 linhas e está no repositório.
frontend/app.py (clique para expandir)
"""DungeonAI — Frontend Streamlit com tema dark fantasy."""
import base64
import streamlit as st
from agent.dungeon_master import play_turn, new_session
# ── Page config ──────────────────────────────────────────
st.set_page_config(
page_title="DungeonAI - Caverns of Eldrath",
page_icon="🗡️",
layout="centered",
initial_sidebar_state="collapsed"
)
# ── Dark fantasy CSS (abbreviated — full version in repo) ──
st.markdown("""
<style>
.stApp { background-color: #0a0a0f; color: #c4b59a; }
.narrative-box {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid #c4975a33;
border-radius: 12px;
padding: 1.5rem;
margin: 1rem 0;
font-size: 1.05rem;
line-height: 1.8;
color: #d4c4a8;
}
.status-bar {
display: flex; gap: 1rem; padding: 0.75rem;
background: #12121f; border-radius: 8px;
border: 1px solid #c4975a22;
margin-bottom: 1rem;
}
.stat { text-align: center; flex: 1; }
.stat-value { font-size: 1.3rem; font-weight: bold; color: #c4975a; }
.stat-label { font-size: 0.7rem; color: #888; text-transform: uppercase; }
/* ... ~350 lines total — see repo for full CSS ... */
</style>
""", unsafe_allow_html=True)
# ── Session state init ───────────────────────────────────
if "session_id" not in st.session_state:
st.session_state.session_id = new_session()
st.session_state.game_started = False
st.session_state.turns = []
st.session_state.pending_action = None
# ── Title ────────────────────────────────────────────────
st.markdown("<h1 style='text-align:center;color:#c4975a'>⚔️ DungeonAI</h1>", unsafe_allow_html=True)
st.markdown("<p style='text-align:center;color:#888'>Caverns of Eldrath</p>", unsafe_allow_html=True)
def queue_action(action: str):
"""Queue a player action to be processed on next rerun."""
st.session_state.pending_action = action
def process_pending_action():
"""Process the queued action with a loading spinner."""
if st.session_state.pending_action is None:
return
action = st.session_state.pending_action
st.session_state.pending_action = None
with st.spinner("O Dungeon Master esta narrando..."):
result = play_turn(st.session_state.session_id, action)
st.session_state.turns.append(result)
st.session_state.game_state = result["game_state"]
# ── Start / Continue ─────────────────────────────────────
if not st.session_state.game_started:
if st.button("Iniciar Aventura", use_container_width=True):
queue_action("Iniciar uma nova aventura nas Cavernas de Eldrath")
st.session_state.game_started = True
st.rerun()
else:
process_pending_action()
# Render turns
for turn in st.session_state.turns:
# Status bar
gs = turn.get("game_state", {})
st.markdown(f"""
<div class="status-bar">
<div class="stat"><div class="stat-value">{gs.get('hp',0)}/{gs.get('max_hp',100)}</div><div class="stat-label">HP</div></div>
<div class="stat"><div class="stat-value">{gs.get('gold',0)}</div><div class="stat-label">Gold</div></div>
<div class="stat"><div class="stat-value">{gs.get('xp',0)}</div><div class="stat-label">XP</div></div>
<div class="stat"><div class="stat-value">Lv{gs.get('level',1)}</div><div class="stat-label">Level</div></div>
</div>
""", unsafe_allow_html=True)
# Scene image
if turn.get("image_base64"):
image_bytes = base64.b64decode(turn["image_base64"])
st.image(image_bytes, use_container_width=True)
# Narrative
st.markdown(f'<div class="narrative-box">{turn["narrative"]}</div>', unsafe_allow_html=True)
# Action buttons (last turn options)
if st.session_state.turns:
last = st.session_state.turns[-1]
if last.get("game_over"):
if st.button("Nova Aventura"):
st.session_state.session_id = new_session()
st.session_state.game_started = False
st.session_state.turns = []
st.rerun()
else:
options = last.get("options", [])
cols = st.columns(len(options)) if options else []
for i, opt in enumerate(options):
with cols[i]:
if st.button(opt, key=f"opt_{len(st.session_state.turns)}_{i}"):
queue_action(opt)
st.rerun()
# Free text input
free_action = st.text_input(
"Ou digite sua propria acao:",
key=f"free_{len(st.session_state.turns)}"
)
if free_action:
queue_action(free_action)
st.rerun()
queue_action + process_pending_action é necessário porque o Streamlit re-executa o script inteiro a cada interação. Se você chamar play_turn direto no callback do botão, o spinner não aparece corretamente.
Rodar o jogo
Com tudo configurado, basta rodar:
python -m streamlit run frontend/app.py
O Streamlit abre no navegador em http://localhost:8501. Clique em Iniciar Aventura e boa sorte nas Cavernas de Eldrath.
Como Funciona por Dentro
Ciclo de um turno
Cada turno segue exatamente 6 passos:
- Jogador envia ação — clica em uma opção ou digita texto livre
- Load state —
get_game_statebusca HP, ouro, XP, inventário, localização e histórico do DynamoDB - Claude gera tudo —
narrate_storyenvia o estado + ação para o Claude Haiku, que retorna JSON com narrativa, prompt de imagem, mudanças de estado e opções - Titan gera imagem —
generate_sceneusa o prompt em inglês para gerar uma imagem 512x512 - Persistência — imagem salva no S3, estado atualizado no DynamoDB via
save_game_state - Retorno — frontend recebe narrativa + imagem + status + opções e renderiza tudo
Por que Strands Agents?
Strands é o framework open-source da AWS para agentes de IA. O que eu mais gostei:
- Decorador
@tool— transforma qualquer função Python em uma ferramenta que o agente pode chamar, com tipagem automática dos parâmetros - Integração nativa com Bedrock —
BedrockModelfunciona direto, sem adapters ou wrappers - Overhead mínimo — não precisa de chains, prompts templates ou parsers extras. O agente chama as tools e pronto
Por que Claude 3.5 Haiku?
Eu testei vários modelos e o Haiku foi o melhor custo-benefício para este caso:
- Velocidade — responde em ~1 segundo, essencial para um jogo interativo
- Custo — ~$0.0008 por turno (input + output tokens)
- Português — narrativas fluidas e naturais em pt-BR
- JSON confiável — segue o formato de saída com consistência. Raramente quebra o JSON
Troubleshooting
| Erro | Causa | Solução |
|---|---|---|
AccessDeniedException no Bedrock |
Modelo não habilitado ou IAM sem permissão | Habilite o modelo no Bedrock Console e verifique a policy IAM |
ValidationException: malformed input |
Payload inválido para Titan Image | Verifique se height e width são múltiplos de 64 e o prompt não está vazio |
TypeError: Float types are not supported |
DynamoDB não aceita float |
Use a função _to_decimal() antes de chamar put_item() |
| JSON parse error no retorno do Claude | Modelo retornou texto fora do formato JSON | O fallback em narrate_story.py já trata isso. Se persistir, aumente a especificidade do system prompt |
NoCredentialError |
AWS credentials não configuradas | Rode aws configure ou verifique o arquivo .env |
O código completo está no github.com/cloudbymcn/dungeonai. Se tiver dúvidas ou sugestões, abre uma issue no repo.