Skip to content

Agentic AI Dungeon Game

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:

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.

Now we can start the dev server with the following command:

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

baseline-website.png

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!

baseline-game.png

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.

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',
};

Now let’s delete the packages/game-ui/src/hooks/useAppLayout.tsx file as it is unused.

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

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 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! 🎉🎉🎉