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 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 serve game-ui

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" />,
});

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.

Recall that in Module 1, we used the connection generator to connect our Game UI to the Story Agent. This set up a type-safe OpenAPI client for interacting with the agent, along with hooks and providers.

The connection generator created the following for us:

  • A StoryAgentProvider component wrapping our app in main.tsx
  • A useStoryAgent hook for TanStack Query integration
  • A useStoryAgentClient hook for direct client access
  • Generated TypeScript types from the agent’s OpenAPI specification

We’ll use the useStoryAgentClient hook in our game component to stream story responses from the agent.

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