Jogo de Dungeons de IA Agêntica
Módulo 4: Implementação da UI
Seção intitulada “Módulo 4: Implementação da UI”Para começar a construir a UI, precisamos configurar nosso servidor de desenvolvimento local para apontar para o sandbox implantado. Execute o seguinte comando:
pnpm nx run @dungeon-adventure/game-ui:load:runtime-config
yarn nx run @dungeon-adventure/game-ui:load:runtime-config
npx nx run @dungeon-adventure/game-ui:load:runtime-config
bunx nx run @dungeon-adventure/game-ui:load:runtime-config
Este comando irá baixar o runtime-config.json
que está implantado e armazená-lo localmente na pasta packages/game-ui/public
.
Agora podemos iniciar o servidor de desenvolvimento com o seguinte comando:
pnpm nx run @dungeon-adventure/game-ui:serve
yarn nx run @dungeon-adventure/game-ui:serve
npx nx run @dungeon-adventure/game-ui:serve
bunx nx run @dungeon-adventure/game-ui:serve
Você pode então abrir o site local em um navegador, onde será solicitado que faça login e siga as instruções para criar um novo usuário. Após concluir, você deve ver o site base:

Você pode executar
Criar uma nova rota ‘/game’
Seção intitulada “Criar uma nova rota ‘/game’”Vamos demonstrar as capacidades do @tanstack/react-router
criando uma nova rota tipada. Para isso, basta criar um arquivo vazio no seguinte local: packages/game-ui/src/routes/game/index.tsx
. Você notará que o arquivo é atualizado imediatamente.
O @tanstack/react-router
já configurou automaticamente sua nova rota e você observará que o arquivo criado já está populado com o caminho da rota:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/game/')({ component: RouteComponent,})
function RouteComponent() { return <div>Hello "/game/"!</div>}
Agora, se você navegar para http://localhost:4200/game
, verá que sua nova página foi renderizada!

Vamos também atualizar o arquivo index.tsx
para carregar nossa nova rota /game
por padrão. Observe como, ao atualizar o campo to
, você tem uma lista de rotas tipadas para escolher.
import { createFileRoute, Navigate } from '@tanstack/react-router';
export const Route = createFileRoute('/')({ component: () => <Navigate to="/game" />,});
Finalmente, podemos excluir a pasta packages/game-ui/src/routes/welcome/
pois não é mais necessária.
Atualizações de layout
Seção intitulada “Atualizações de layout”O layout padrão configurado é mais adequado para aplicativos empresariais no estilo SaaS do que para um jogo. Vamos reconfigurar o layout e retematizá-lo para se assemelhar mais a um jogo de masmorra.
Faça as seguintes alterações em packages/game-ui/src
:
export default { applicationName: 'Dungeon Adventure',};
import { useAuth } from 'react-oidc-context';import * as React from 'react';import Config from '../../config';import { TopNavigation } from '@cloudscape-design/components';
/** * Defines the App layout and contains logic for routing. */const AppLayout: React.FC<React.PropsWithChildren> = ({ children }) => { const { user, removeUser, signoutRedirect, clearStaleState } = useAuth();
return ( <> <TopNavigation identity={{ href: '/', title: Config.applicationName, }} utilities={[ { type: 'menu-dropdown', text: `${user?.profile?.['cognito:username']}`, iconName: 'user-profile-active', onItemClick: (e) => { if (e.detail.id === 'signout') { removeUser(); signoutRedirect({ post_logout_redirect_uri: window.location.origin, extraQueryParams: { redirect_uri: window.location.origin, response_type: 'code', }, }); clearStaleState(); } }, items: [{ id: 'signout', text: 'Sign out' }], }, ]} /> { children } </> );};export default AppLayout;
/* Game styles */:root { --primary-color: rgba(252, 214, 112, 1); --secondary-color: rgba(252, 214, 112, 0.8); --inventory-color: rgba(144, 238, 144, 0.85); --inventory-border: rgba(34, 139, 34, 1); --user-color: rgba(135, 206, 250, 0.85); --user-border: rgba(30, 144, 255, 1); --background-dark: #161d26; --background-light: #2a2c3c; --text-light: #e1e1e6; --text-dark: #1f2937;}
div#root { min-height: 100vh; display: flex; flex-direction: column;}
html,body { margin: 0; padding: 0; min-height: 100vh; width: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: var(--background-dark); color: var(--text-dark);}
/* Game container and interface */.game-interface { margin: 2rem; min-height: 100%; flex-grow: 1; display: flex; flex-direction: column;}
/* Header styles */.game-header { text-align: center; margin-bottom: 2rem; padding: 1rem;}
.game-header h1 { font-size: 2.5rem; font-weight: bold; background: linear-gradient(45deg, #ffd700, #ff6b6b); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); margin: 0;}
/* Saved games section */.saved-games { margin-bottom: 2rem;}
.saved-games h2,.new-game h2 { font-size: 1.5rem; margin-bottom: 1rem; color: var(--text-light);}
.game-list { display: flex; flex-direction: column; gap: 0.5rem;}
.game-session { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 1rem; cursor: pointer; transition: all 0.3s ease; width: 100%; text-align: left;}
.game-session:hover { transform: translateY(-2px); background: rgba(255, 255, 255, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);}
.player-name { font-weight: 600; margin-bottom: 0.25rem; color: var(--text-light);}
.genre-name { font-size: 0.875rem; color: rgba(255, 255, 255, 0.7);}
/* New game section */.new-game { margin-top: 2rem;}
.game-setup { display: flex; flex-direction: column; gap: 1rem;}
span[data-style='generating'] { color: rgba(252, 214, 112, 1) !important;}
.name-input,.action-input { width: 100%; padding: 0.75rem; /* background: rgba(255, 255, 255, 0.1); */ border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 8px; /* color: var(--text-light); */ font-size: 1rem;}
.name-input:focus,.action-input:focus { outline: none; border-color: var(--primary-color); /* background: rgba(255, 255, 255, 0.15); */}
.genre-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;}
.genre-button { background: rgba(255, 255, 255, 0.1); border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 1rem; color: var(--text-light); font-weight: 600; cursor: pointer; transition: all 0.3s ease;}
.genre-button:hover { background: rgba(252, 214, 112, 1); color: var(--text-dark); border-color: var(--primary-color); transform: translateY(-2px);}
/* Messages area */.messages-area { flex: 1; overflow-y: auto; margin-bottom: 1rem; align-content: flex-end;}
.messages-container { display: flex; flex-direction: column; gap: 1rem; padding: 1rem;}
.message { padding: 1rem; border-radius: 8px; max-width: 80%; line-height: 1.5;}
.message-header { font-weight: 600; margin-bottom: 0.5rem; font-size: 1.1rem;}
.message-content { word-wrap: break-word; white-space: pre-wrap;}
.message.assistant { background: var(--secondary-color); border-left: 4px solid var(--primary-color); margin-right: auto; margin: 0 !important;}
.message.assistant .message-header { color: var(--text-dark);}
.message.user { background: var(--user-color); border-right: 4px solid var(--user-border); margin-left: auto;}
.message.user .message-header { color: var(--text-dark);}
.inventory-overlay { position: fixed; top: 1rem; right: 1rem; background: var(--inventory-color); border: 4px solid var(--inventory-border); border-radius: 8px; padding: 1rem; max-width: 250px; z-index: 1000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);}
.inventory-header { font-weight: 600; margin-bottom: 0.5rem; color: var(--text-dark); font-size: 1.1rem;}
.inventory-items { display: flex; flex-direction: column; gap: 0.25rem;}
.inventory-item { color: var(--text-dark); padding: 0.25rem 0; font-size: 0.95rem;}
/* Input area */.input-area { padding: 1rem; position: sticky; bottom: 0;}
/* Scrollbar styling */.messages-area::-webkit-scrollbar { width: 6px;}
.messages-area::-webkit-scrollbar-track { background: transparent;}
.messages-area::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.3); border-radius: 3px;}
/* For Firefox */.messages-area { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.3) transparent;}
Agora vamos excluir o arquivo packages/game-ui/src/hooks/useAppLayout.tsx
pois não está sendo usado.
Integração do Agente de História
Seção intitulada “Integração do Agente de História”Em seguida, criaremos um hook para inicializar um cliente de interação com nosso Agente de História.
import { useAuth } from 'react-oidc-context';import { useRuntimeConfig } from './useRuntimeConfig';import { useMemo } from 'react';
export interface GenerateStoryInput { playerName: string; genre: string; actions: { role: string; content: string }[];}
const generateSessionId = (playerName: string): string => { const targetLength = 34; const uuidLength = targetLength - playerName.length;
const randomSegment = crypto .randomUUID() .replace(/-/g, '') .substring(0, uuidLength);
return `${playerName}${randomSegment}`;};
export const useStoryAgent = () => { const { agentArn } = useRuntimeConfig(); const region = agentArn.split(':')[3]; const url = `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/${encodeURIComponent(agentArn)}/invocations?qualifier=DEFAULT`;
const { user } = useAuth();
return useMemo( () => ({ generateStory: async function* ( opts: GenerateStoryInput, ): AsyncIterableIterator<string> { const response = await fetch(url, { headers: { Authorization: `Bearer ${user?.id_token}`, 'Content-Type': 'application/json', 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': generateSessionId( opts.playerName, ), }, method: 'POST', body: JSON.stringify(opts), });
const reader = response.body ?.pipeThrough(new TextDecoderStream()) .getReader();
if (!reader) return;
while (true) { const { value, done } = await reader.read();
if (done) return;
// Parse SSE format - each chunk may contain multiple events const lines = value.split('\n');
for (const line of lines) { // SSE events start with "data: " if (line.startsWith('data: ')) { const data = line.slice(6); // Remove "data: " prefix
try { const parsed = JSON.parse(data);
// Extract text from contentBlockDelta events if (parsed.event?.contentBlockDelta?.delta?.text) { yield parsed.event.contentBlockDelta.delta.text; } if (parsed.event?.messageStop) { yield '\n'; } } catch (e) { // Skip lines that aren't valid JSON (like Python debug output) continue; } } } } }, }), [url, user?.id_token], );};
Isso faz o seguinte:
- Recupera o ARN do Agente do
runtime-config.json
- Constrói a URL de invocação do AgentCore Runtime a partir do ARN
- Invoca o Agente com o token JWT do usuário autenticado e um ID de sessão aleatório
- Retorna um iterador assíncrono para consumo de chunks de mensagens transmitidas
Você notará que a integração com nossa Game API já está configurada - não precisamos fazer nada manualmente pois usamos o gerador api-connection
. Por favor, dê +1 nesta issue do GitHub se desejar uma experiência similar para agentes.
Páginas do jogo
Seção intitulada “Páginas do jogo”Vamos criar as páginas do jogo que chamarão nossas APIs e finalizarão a implementação do jogo. Atualize os seguintes arquivos em packages/game-ui/src/routes/game
:
import { FormField, Spinner } from '@cloudscape-design/components';import { useInfiniteQuery, useMutation } from '@tanstack/react-query';import { createFileRoute, useNavigate } from '@tanstack/react-router';import { createRef, LegacyRef, MutableRefObject, useEffect, useMemo, useState,} from 'react';import { useGameApi } from '../../hooks/useGameApi';import { IAction, IGame } from ':dungeon-adventure/game-api';
type IGameState = Omit<IGame, 'lastUpdated'> & { actions: IAction[] };
export const Route = createFileRoute('/game/')({ component: RouteComponent,});
// hook to check if a ref is visible on the screenexport function useIsVisible(ref: MutableRefObject<any>) { const [isIntersecting, setIntersecting] = useState(false);
useEffect(() => { const observer = new IntersectionObserver(([entry]) => setIntersecting(entry.isIntersecting), );
ref.current && observer.observe(ref.current); return () => { observer.disconnect(); }; }, [ref]);
return isIntersecting;}
function RouteComponent() { const [playerName, setPlayerName] = useState(''); const navigate = useNavigate(); const ref = createRef(); const isLastGameVisible = useIsVisible(ref);
const gameApi = useGameApi(); const saveGameMutation = useMutation(gameApi.games.save.mutationOptions());
const { data: gamesPages, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery( gameApi.games.query.infiniteQueryOptions( { limit: 10 }, { getNextPageParam: ({ cursor }) => cursor }, ), ); const games = useMemo(() => { return gamesPages?.pages.flatMap((page) => page.items) || []; }, [gamesPages]);
// Fetch more games if the last game is visible and there are more games useEffect(() => { if (isLastGameVisible && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, [isFetchingNextPage, hasNextPage, fetchNextPage, isLastGameVisible]);
const playerAlreadyExists = (playerName?: string) => { return !!games?.find((s) => s.playerName === playerName); };
// create a new game const handleStartGame = async ( playerName: string, genre: IGameState['genre'], ) => { if (playerAlreadyExists(playerName)) { return; } try { await saveGameMutation.mutateAsync({ playerName, genre, });
await handleLoadGame(playerName, genre); } catch (error) { console.error('Failed to start game:', error); } };
// load an existing game const handleLoadGame = async ( playerName: string, genre: IGameState['genre'], ) => { await navigate({ to: '/game/$playerName', params: { playerName }, search: { genre }, }); };
return ( <div className="game-interface"> <header className="game-header"> <h1>AI Dungeon Adventure</h1> </header>
{/* New Game Section */} <div className="new-game"> <h2>Start New Game</h2> <div className="game-setup"> <FormField errorText={ playerAlreadyExists(playerName) ? `${playerName} already exists` : undefined } > <input type="text" placeholder="Enter your name" className="name-input" onChange={(e) => setPlayerName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { const input = e.currentTarget; handleStartGame(input.value, 'medieval'); } }} /> </FormField> <div className="genre-grid"> {(['zombie', 'superhero', 'medieval'] as const).map((genre) => ( <button key={genre} className="genre-button" onClick={() => { const playerName = document.querySelector('input')?.value; if (playerName) { handleStartGame(playerName, genre); } }} > {genre.charAt(0).toUpperCase() + genre.slice(1)} </button> ))} </div> </div> </div>
{/* Saved Games Section */} {games && games.length > 0 && ( <div className="saved-games"> <h2>Continue Game</h2> <div className="game-list"> {games.map((game, idx) => ( <button key={game.playerName} ref={ idx === games.length - 1 ? (ref as LegacyRef<HTMLButtonElement>) : undefined } onClick={() => handleLoadGame(game.playerName, game.genre)} className="game-session" > <div className="player-name">{game.playerName}</div> <div className="genre-name"> {game.genre.charAt(0).toUpperCase() + game.genre.slice(1)} </div> </button> ))} {isFetchingNextPage && <Spinner data-style="generating" size="big" />} </div> </div> )} </div> );}
import { PromptInput, Spinner } from '@cloudscape-design/components';import { useMutation, useQuery } from '@tanstack/react-query';import { createFileRoute } from '@tanstack/react-router';import { useEffect, useRef, useState } from 'react';import { useGameApi } from '../../hooks/useGameApi';import { useStoryAgent } from '../../hooks/useStoryAgent';import type { IAction, IGame } from ':dungeon-adventure/game-api';
type IGameState = Omit<IGame, 'lastUpdated'> & { actions: IAction[] };
export const Route = createFileRoute('/game/$playerName')({ component: RouteComponent, validateSearch: (search: Record<string, unknown>) => { return { genre: search.genre as IGameState['genre'], }; },});
function RouteComponent() { const { playerName } = Route.useParams(); const { genre } = Route.useSearch();
const [currentInput, setCurrentInput] = useState(''); const [streamingContent, setStreamingContent] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// Get genre-specific icon for assistant messages const getAssistantIcon = () => { switch (genre) { case 'zombie': return '🧟'; case 'superhero': return '🦸'; case 'medieval': return '⚔️'; default: return '📖'; } };
const gameApi = useGameApi(); const storyAgent = useStoryAgent(); const saveActionMutation = useMutation( gameApi.actions.save.mutationOptions(), ); const gameActionsQuery = useQuery( gameApi.actions.query.queryOptions({ playerName, limit: 100 }), ); const inventoryQuery = useQuery( gameApi.inventory.query.queryOptions({ playerName, limit: 100 }), );
// no actions - therefore must be a new game - generate initial story useEffect(() => { if ( !gameActionsQuery.isLoading && gameActionsQuery.data?.items && gameActionsQuery.data?.items.length === 0 ) { generateStory({ playerName, genre, actions: [], }); } }, [gameActionsQuery.data?.items, gameActionsQuery.isLoading]);
const generateStoryMutation = useMutation({ mutationFn: async ({ playerName, genre, actions }: IGameState) => { let content = ''; for await (const chunk of storyAgent.generateStory({ playerName, genre, actions, })) { content += chunk; // make chunks available to render in a streaming fashion setStreamingContent(content); }
return content; }, });
// scroll to the last message const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); };
// scroll to the bottom whenever gameActionsQuery is fetched or whenever streaming content changes useEffect(() => { scrollToBottom(); }, [streamingContent, gameActionsQuery]);
// progress the story const generateStory = async ({ playerName, genre, actions }: IGameState) => { try { const content = await generateStoryMutation.mutateAsync({ playerName, genre, actions, });
// Save assistant's response await saveActionMutation.mutateAsync({ playerName, role: 'assistant', content, });
await gameActionsQuery.refetch(); setStreamingContent('');
await inventoryQuery.refetch(); } catch (error) { console.error('Failed to generate story:', error); } };
// progress the story when the user submits input const handleSubmitAction = async () => { if (!currentInput.trim()) return;
const userAction: IAction = { playerName, role: 'user' as const, content: currentInput, timestamp: new Date().toISOString(), };
// Save user action await saveActionMutation.mutateAsync(userAction); await gameActionsQuery.refetch();
setCurrentInput('');
// Generate response await generateStory({ genre, playerName, actions: [...(gameActionsQuery.data?.items ?? []), userAction], }); };
return ( <div className="game-interface"> {inventoryQuery.data?.items && inventoryQuery.data.items.length > 0 && ( <div className="inventory-overlay"> <div className="inventory-header"> 📦 Inventory{' '} {inventoryQuery.isFetching ? ( <Spinner data-style="generating" size="normal" /> ) : null} </div> <div className="inventory-items"> {inventoryQuery.data.items.map((item, idx) => ( <div key={idx} className="inventory-item"> {item.emoji ?? null} {item.itemName}{' '} {item.quantity > 1 && `(x${item.quantity})`} </div> ))} </div> </div> )}
<div className="messages-area"> <div className="messages-container"> {gameActionsQuery.data?.items .concat( streamingContent.length > 0 ? [ { playerName, role: 'assistant', content: streamingContent, timestamp: new Date().toISOString(), }, ] : [], ) .map((action, i) => ( <div key={i}> <div className={`message ${ action.role === 'assistant' ? 'assistant' : 'user' }`} > <div className="message-header"> {action.role === 'assistant' ? `${getAssistantIcon()} Story` : `⭐️ ${playerName}`} </div> <div className="message-content">{action.content}</div> </div> </div> ))} {generateStoryMutation.isPending && streamingContent.length === 0 && ( <Spinner data-style="generating" size="big" /> )} <div ref={messagesEndRef} /> </div> </div> <div className="input-area"> <PromptInput onChange={({ detail }) => setCurrentInput(detail.value)} value={currentInput} actionButtonAriaLabel="Send message" actionButtonIconName="send" ariaLabel="Default prompt input" placeholder="What do you do?" onAction={handleSubmitAction} /> </div> </div> );}
Após essas alterações, seu servidor de desenvolvimento local (http://localhost:4200/) deve ter seu jogo pronto para jogar!
Compilar e Implantar
Para compilar seu código, execute:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Agora implante sua aplicação:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox/*
Após a implantação, navegue até sua URL do Cloudfront que pode ser encontrada inspecionando as saídas da implantação do CDK.


Parabéns. Você construiu e implantou seu Jogo de Aventura em Masmorra com Agentes! 🎉🎉🎉