Skip to content

AI Dungeon Game

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:

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

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. Pay close attention to the dev server logs:

Terminal window
♻️ Regenerating routes...
🟡 Updating /Users/dimecha/dungeon-adventure/packages/game-ui/src/routes/game/index.tsx
🟡 Updating /Users/dimecha/dungeon-adventure/packages/game-ui/src/routeTree.gen.ts
Processed routes in 27ms

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.

packages/game-ui/src/routes/index.tsx
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

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:

packages/game-ui/src/config.ts
export default {
applicationName: 'Dungeon Adventure',
logo: 'data:image/svg+xml;base64,PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KDTwhLS0gVXBsb2FkZWQgdG86IFNWRyBSZXBvLCB3d3cuc3ZncmVwby5jb20sIFRyYW5zZm9ybWVkIGJ5OiBTVkcgUmVwbyBNaXhlciBUb29scyAtLT4KPHN2ZyBmaWxsPSIjMjQ4YmFlIiB3aWR0aD0iODAwcHgiIGhlaWdodD0iODAwcHgiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHN0cm9rZT0iIzI0OGJhZSI+Cg08ZyBpZD0iU1ZHUmVwb19iZ0NhcnJpZXIiIHN0cm9rZS13aWR0aD0iMCIvPgoNPGcgaWQ9IlNWR1JlcG9fdHJhY2VyQ2FycmllciIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cg08ZyBpZD0iU1ZHUmVwb19pY29uQ2FycmllciI+Cg08dGl0bGU+aW9uaWNvbnMtdjVfbG9nb3M8L3RpdGxlPgoNPHBhdGggZD0iTTQxMC42NiwxODAuNzJoMHEtNy42Ny0yLjYyLTE1LjQ1LTQuODgsMS4yOS01LjI1LDIuMzgtMTAuNTZjMTEuNy01Ni45LDQuMDUtMTAyLjc0LTIyLjA2LTExNy44My0yNS0xNC40OC02NiwuNjEtMTA3LjM2LDM2LjY5cS02LjEsNS4zNC0xMS45NSwxMS0zLjktMy43Ni04LTcuMzZjLTQzLjM1LTM4LjU4LTg2LjgtNTQuODMtMTEyLjg4LTM5LjY5LTI1LDE0LjUxLTMyLjQzLDU3LjYtMjEuOSwxMTEuNTNxMS41OCw4LDMuNTUsMTUuOTNjLTYuMTUsMS43NS0xMi4wOSwzLjYyLTE3Ljc3LDUuNkM0OC40NiwxOTguOSwxNiwyMjYuNzMsMTYsMjU1LjU5YzAsMjkuODIsMzQuODQsNTkuNzIsODcuNzcsNzcuODVxNi40NCwyLjE5LDEzLDQuMDdRMTE0LjY0LDM0NiwxMTMsMzU0LjY4Yy0xMCw1My0yLjIsOTUuMDcsMjIuNzUsMTA5LjQ5LDI1Ljc3LDE0Ljg5LDY5LS40MSwxMTEuMTQtMzcuMzFxNS00LjM4LDEwLTkuMjUsNi4zMiw2LjExLDEzLDExLjg2YzQwLjgsMzUuMTgsODEuMDksNDkuMzksMTA2LDM0LjkzLDI1Ljc1LTE0Ljk0LDM0LjEyLTYwLjE0LDIzLjI1LTExNS4xM3EtMS4yNS02LjMtMi44OC0xMi44Niw0LjU2LTEuMzUsOC45My0yLjc5YzU1LTE4LjI3LDkwLjgzLTQ3LjgxLDkwLjgzLTc4QzQ5NiwyMjYuNjIsNDYyLjUsMTk4LjYxLDQxMC42NiwxODAuNzJabS0xMjktODEuMDhjMzUuNDMtMzAuOTEsNjguNTUtNDMuMTEsODMuNjUtMzQuMzloMGMxNi4wNyw5LjI5LDIyLjMyLDQ2Ljc1LDEyLjIyLDk1Ljg4cS0xLDQuOC0yLjE2LDkuNTdhNDg3LjgzLDQ4Ny44MywwLDAsMC02NC4xOC0xMC4xNiw0ODEuMjcsNDgxLjI3LDAsMCwwLTQwLjU3LTUwLjc1UTI3NiwxMDQuNTcsMjgxLjY0LDk5LjY0Wk0xNTcuNzMsMjgwLjI1cTYuNTEsMTIuNiwxMy42MSwyNC44OSw3LjIzLDEyLjU0LDE1LjA3LDI0LjcxYTQzNS4yOCw0MzUuMjgsMCwwLDEtNDQuMjQtNy4xM0MxNDYuNDEsMzA5LDE1MS42MywyOTQuNzUsMTU3LjczLDI4MC4yNVptMC00OC4zM2MtNi0xNC4xOS0xMS4wOC0yOC4xNS0xNS4yNS00MS42MywxMy43LTMuMDcsMjguMy01LjU4LDQzLjUyLTcuNDhxLTcuNjUsMTEuOTQtMTQuNzIsMjQuMjNUMTU3LjcsMjMxLjkyWm0xMC45LDI0LjE3cTkuNDgtMTkuNzcsMjAuNDItMzguNzhoMHExMC45My0xOSwyMy4yNy0zNy4xM2MxNC4yOC0xLjA4LDI4LjkyLTEuNjUsNDMuNzEtMS42NXMyOS41Mi41Nyw0My43OSwxLjY2cTEyLjIxLDE4LjA5LDIzLjEzLDM3dDIwLjY5LDM4LjZRMzM0LDI3NS42MywzMjMsMjk0LjczaDBxLTEwLjkxLDE5LTIzLDM3LjI0Yy0xNC4yNSwxLTI5LDEuNTUtNDQsMS41NXMtMjkuNDctLjQ3LTQzLjQ2LTEuMzhxLTEyLjQzLTE4LjE5LTIzLjQ2LTM3LjI5VDE2OC42LDI1Ni4wOVpNMzQwLjc1LDMwNXE3LjI1LTEyLjU4LDEzLjkyLTI1LjQ5aDBhNDQwLjQxLDQ0MC40MSwwLDAsMSwxNi4xMiw0Mi4zMkE0MzQuNDQsNDM0LjQ0LDAsMCwxLDMyNiwzMjkuNDhRMzMzLjYyLDMxNy4zOSwzNDAuNzUsMzA1Wm0xMy43Mi03My4wN3EtNi42NC0xMi42NS0xMy44MS0yNWgwcS03LTEyLjE4LTE0LjU5LTI0LjA2YzE1LjMxLDEuOTQsMzAsNC41Miw0My43Nyw3LjY3QTQzOS44OSw0MzkuODksMCwwLDEsMzU0LjQ3LDIzMS45M1pNMjU2LjIzLDEyNC40OGgwYTQzOS43NSw0MzkuNzUsMCwwLDEsMjguMjUsMzQuMThxLTI4LjM1LTEuMzUtNTYuNzQsMEMyMzcuMDcsMTQ2LjMyLDI0Ni42MiwxMzQuODcsMjU2LjIzLDEyNC40OFpNMTQ1LjY2LDY1Ljg2YzE2LjA2LTkuMzIsNTEuNTcsNCw4OSwzNy4yNywyLjM5LDIuMTMsNC44LDQuMzYsNy4yLDYuNjdBNDkxLjM3LDQ5MS4zNywwLDAsMCwyMDEsMTYwLjUxYTQ5OS4xMiw0OTkuMTIsMCwwLDAtNjQuMDYsMTBxLTEuODMtNy4zNi0zLjMtMTQuODJoMEMxMjQuNTksMTA5LjQ2LDEzMC41OCw3NC42MSwxNDUuNjYsNjUuODZaTTEyMi4yNSwzMTcuNzFxLTYtMS43MS0xMS44NS0zLjcxYy0yMy40LTgtNDIuNzMtMTguNDQtNTYtMjkuODFDNDIuNTIsMjc0LDM2LjUsMjYzLjgzLDM2LjUsMjU1LjU5YzAtMTcuNTEsMjYuMDYtMzkuODUsNjkuNTItNTVxOC4xOS0yLjg1LDE2LjUyLTUuMjFhNDkzLjU0LDQ5My41NCwwLDAsMCwyMy40LDYwLjc1QTUwMi40Niw1MDIuNDYsMCwwLDAsMTIyLjI1LDMxNy43MVptMTExLjEzLDkzLjY3Yy0xOC42MywxNi4zMi0zNy4yOSwyNy44OS01My43NCwzMy43MmgwYy0xNC43OCw1LjIzLTI2LjU1LDUuMzgtMzMuNjYsMS4yNy0xNS4xNC04Ljc1LTIxLjQ0LTQyLjU0LTEyLjg1LTg3Ljg2cTEuNTMtOCwzLjUtMTZhNDgwLjg1LDQ4MC44NSwwLDAsMCw2NC42OSw5LjM5LDUwMS4yLDUwMS4yLDAsMCwwLDQxLjIsNTFDMjM5LjU0LDQwNS44MywyMzYuNDksNDA4LjY1LDIzMy4zOCw0MTEuMzhabTIzLjQyLTIzLjIyYy05LjcyLTEwLjUxLTE5LjQyLTIyLjE0LTI4Ljg4LTM0LjY0cTEzLjc5LjU0LDI4LjA4LjU0YzkuNzgsMCwxOS40Ni0uMjEsMjktLjY0QTQzOS4zMyw0MzkuMzMsMCwwLDEsMjU2LjgsMzg4LjE2Wm0xMjQuNTIsMjguNTljLTIuODYsMTUuNDQtOC42MSwyNS43NC0xNS43MiwyOS44Ni0xNS4xMyw4Ljc4LTQ3LjQ4LTIuNjMtODIuMzYtMzIuNzItNC0zLjQ0LTgtNy4xMy0xMi4wNy0xMWE0ODQuNTQsNDg0LjU0LDAsMCwwLDQwLjIzLTUxLjIsNDc3Ljg0LDQ3Ny44NCwwLDAsMCw2NS0xMC4wNXExLjQ3LDUuOTQsMi42LDExLjY0aDBDMzgzLjgxLDM3Ny41OCwzODQuNSwzOTkuNTYsMzgxLjMyLDQxNi43NVptMTcuNC0xMDIuNjRoMGMtMi42Mi44Ny01LjMyLDEuNzEtOC4wNiwyLjUzYTQ4My4yNiw0ODMuMjYsMCwwLDAtMjQuMzEtNjAuOTQsNDgxLjUyLDQ4MS41MiwwLDAsMCwyMy4zNi02MC4wNmM0LjkxLDEuNDMsOS42OCwyLjkzLDE0LjI3LDQuNTIsNDQuNDIsMTUuMzIsNzEuNTIsMzgsNzEuNTIsNTUuNDNDNDc1LjUsMjc0LjE5LDQ0Ni4yMywyOTguMzMsMzk4LjcyLDMxNC4xMVoiLz4KDTxwYXRoIGQ9Ik0yNTYsMjk4LjU1YTQzLDQzLDAsMSwwLTQyLjg2LTQzQTQyLjkxLDQyLjkxLDAsMCwwLDI1NiwyOTguNTVaIi8+Cg08L2c+Cg08L3N2Zz4=',
};

Now let’s delete the packages/game-ui/src/components/AppLayout/navitems.ts and packages/game-ui/src/hooks/useAppLayout.tsx files as they are unused.

Game pages

Let’s create the Game pages which will call our APIs and finish our game implementation:

packages/game-ui/src/routes/game/index.tsx
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-schema';
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 Dungeon Adventure Game! 🎉🎉🎉