Agentic AI Dungeon Game
Module 4: UI implementation
Section titled “Module 4: UI implementation”To start building the UI, we want to configure our local dev server to point to our deployed sandbox. To do this, run the following command:
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
This command will pull down the runtime-config.json
that is deployed and store it locally within the packages/game-ui/public
folder.
Now we can start the dev server with the following command:
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
You can then open up your local website in a browser at which time you will be prompted to log in and follow the prompts to create a new user. Once completed you should see the baseline website:

Create a new ‘/game’ route
Section titled “Create a new ‘/game’ route”Let’s showcase the capabilities of @tanstack/react-router
by creating a new type-safe route. To do this, simply create an empty file at the following location: packages/game-ui/src/routes/game/index.tsx
. You will notice the file is immediately updated.
The @tanstack/react-router
automatically has configured your new route and you will notice that the file you just created is already populated with the route path:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/game/')({ component: RouteComponent,})
function RouteComponent() { return <div>Hello "/game/"!</div>}
Now if you navigate to http://localhost:4200/game
you will see your new page has been rendered!

Let’s also update the index.tsx
file to load our new /game
route by default. Notice how when you update the to
field, you have a list of type-safe routes to choose from.
import { createFileRoute, Navigate } from '@tanstack/react-router';
export const Route = createFileRoute('/')({ component: () => <Navigate to="/game" />,});
Finally we can delete the packages/game-ui/src/routes/welcome/
folder as this is no longer required.
Layout updates
Section titled “Layout updates”The default layout that is configured is more akin to a SaaS style business application than a game. We are going to re-configure the layout and re-theme it to be more akin to a dungeon style game.
Let’s make the following changes to 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;}
Now let’s delete the packages/game-ui/src/hooks/useAppLayout.tsx
file as it is unused.
Story Agent Integration
Section titled “Story Agent Integration”Next we’ll create a hook which we can use to initialise a client for interacting with our Story Agent.
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], );};
This does the following:
- Retrieves the Agent ARN from
runtime-config.json
- Constructs the AgentCore Runtime invocation url from the ARN
- Invokes the Agent with the signed-in user’s JWT token, and a random session ID
- Returns an async iterator for consumption of streamed agent message chunks
Game pages
Section titled “Game pages”Let’s create the Game pages which will call our APIs and finish our game implementation. Update the following files in 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> );}
Once you make these changes, your local dev server (http://localhost:4200/) should now have your game ready to play!
Build and Deploy
To build your code, run the following command:
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
Now deploy your application:
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/*
Once deployed, navigate to your Cloudfront url which can be found by inspecting the cdk deploy outputs.


Congratulations. You have built and deployed your Agentic Dungeon Adventure Game! 🎉🎉🎉