Skip to content

Build the UI

To start building the UI, we will need to configure our local dev server to point to our deployed sandbox. To do this, run the following command:

Terminal window
pnpm 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.

To start the dev server, run the following command:

Terminal window
pnpm nx run @dungeon-adventure/game-ui:serve

Open your local website in a browser, where you will be prompted to log in and follow the prompts to create a new user. Once completed, you should see the baseline website:

baseline-website.png

Let’s showcase the capabilities of @tanstack/react-router by creating a new type-safe route. To do this, 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 configures your new route and 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>
}

If you navigate to http://localhost:4200/game, you will see your new page has been rendered.

baseline-game.png

Update the index.tsx file to load our new /game route by default. 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" />,
});

You can delete the packages/game-ui/src/routes/welcome/ folder as this is no longer required.

The default layout that is configured is similar to a SaaS style business application than a game. To reconfigure the layout and re-theme it to be more like a dungeon style game, make the following changes to packages/game-ui/src:

export default {
applicationName: 'Dungeon Adventure',
};

Delete the packages/game-ui/src/hooks/useAppLayout.tsx file as it is unused.

Let us create a hook 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 }[];
}
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',
},
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;
yield value;
}
},
}),
[url, user?.id_token],
);
};

This hook:

  • 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, and
  • Returns an async iterator for consumption of streamed agent message chunks.

To 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 screen
export 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>
);
}

Once you make these changes, your local dev server (http://localhost:4200/) should now have your game ready to play.

You can also build & deploy your code to Cloudfront if you prefer.
game-select.png
game-conversation.png

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