Skip to content

Build the UI

Start the full local stack — the game-ui dev server together with a local Game API and a local AG-UI Story Agent (which in turn boots the Inventory MCP server) — with one command:

Terminal window
pnpm nx dev game-ui

The dev target on game-ui has dependsOn on game-api:dev and dungeon_adventure.story:agent-dev, so Nx spins up every project’s local server in parallel. Through the connections we set up in Module 1, those in turn boot the Inventory MCP server and DynamoDB Local. Make sure your container engine is running, then open the dev server in a browser.

Task 2: Where CopilotKit is already wired up

Section titled “Task 2: Where CopilotKit is already wired up”

When you ran the connection generator for game-ui → story in Module 1, the Shadcn website’s AG-UI integration was generated for you. It’s worth a quick look:

  • Directorypackages/game-ui/src/
    • Directorycomponents/
      • AguiProvider.tsx Single CopilotKitProvider registered with every AG-UI agent.
      • Directorycopilot/
        • index.tsx Re-exports themed CopilotChat / CopilotSidebar / CopilotPopup.
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • Directoryhooks/
      • useAguiStoryAgent.tsx Instantiates an @ag-ui/client HttpAgent pointing at the deployed Story Agent and pads threadId to AgentCore’s 33-character minimum session id.
    • main.tsx Wraps <App /> in <AguiProvider>

All we need to do is drop a <CopilotChat agentId="agent" threadId={...} /> into a route. For more detail on how the integration is put together, see the React → AG-UI connection guide.

Replace packages/game-ui/src/styles.css — this is the only file we change for styling. It imports the shared Shadcn globals, overrides the palette to a torch-lit dungeon theme, and makes CopilotKit inherit those colours:

@import '../../common/shadcn/src/styles/globals.css';
@source './**/*.{ts,tsx}';
/* Dungeon theme — torch-lit parchment on stone. Applied to `:root` for the
* page and to `[data-copilotkit][data-copilotkit]` for CopilotKit's chat
* surface; CopilotKit ships a same-specificity `[data-copilotkit]` rule that
* resets `--background` back to white, so we bump specificity with the
* doubled selector. */
:root,
[data-copilotkit][data-copilotkit] {
--background: oklch(0.18 0.02 60);
--foreground: oklch(0.92 0.04 85);
--card: oklch(0.22 0.03 60);
--card-foreground: oklch(0.92 0.04 85);
--popover: oklch(0.2 0.02 60);
--popover-foreground: oklch(0.92 0.04 85);
--primary: oklch(0.75 0.15 75);
--primary-foreground: oklch(0.15 0.02 60);
--secondary: oklch(0.28 0.04 60);
--secondary-foreground: oklch(0.92 0.04 85);
--muted: oklch(0.25 0.02 60);
--muted-foreground: oklch(0.7 0.04 85);
--accent: oklch(0.4 0.12 30);
--accent-foreground: oklch(0.95 0.04 85);
--destructive: oklch(0.55 0.22 25);
--border: oklch(0.35 0.03 60);
--input: oklch(0.3 0.03 60);
--ring: oklch(0.75 0.15 75);
--sidebar: oklch(0.15 0.02 60);
--sidebar-foreground: oklch(0.88 0.04 85);
--sidebar-primary: oklch(0.75 0.15 75);
--sidebar-primary-foreground: oklch(0.15 0.02 60);
--sidebar-accent: oklch(0.28 0.04 60);
--sidebar-accent-foreground: oklch(0.92 0.04 85);
--sidebar-border: oklch(0.3 0.03 60);
--sidebar-ring: oklch(0.75 0.15 75);
}
body {
font-family: 'Georgia', 'Cambria', serif;
background:
radial-gradient(circle at 20% 10%, oklch(0.25 0.05 70 / 0.4), transparent 40%),
radial-gradient(circle at 80% 90%, oklch(0.25 0.1 30 / 0.3), transparent 40%),
var(--background);
}
h1, h2, h3 {
letter-spacing: 0.05em;
}

We need two routes — one to pick a hero, one to play. Both use shadcn components and the CopilotKit chat; there is no hand-rolled chat UI.

import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from ':dungeon-adventure/common-shadcn/components/ui/button';
import { Input } from ':dungeon-adventure/common-shadcn/components/ui/input';
import {
Card,
CardContent,
} from ':dungeon-adventure/common-shadcn/components/ui/card';
import { Spinner } from ':dungeon-adventure/common-shadcn/components/ui/spinner';
import { useGameApi } from '../hooks/useGameApi';
import type { IGame } from ':dungeon-adventure/game-api';
const GENRES = ['medieval', 'zombie', 'superhero'] as const;
export const Route = createFileRoute('/')({ component: RouteComponent });
function RouteComponent() {
const [playerName, setPlayerName] = useState('');
const [pending, setPending] = useState<IGame['genre'] | null>(null);
const navigate = useNavigate();
const gameApi = useGameApi();
const saveGame = useMutation(gameApi.games.save.mutationOptions());
const games = useInfiniteQuery(
gameApi.games.query.infiniteQueryOptions(
{ limit: 10 },
{ getNextPageParam: ({ cursor }) => cursor ?? undefined },
),
);
const savedGames = useMemo(
() => games.data?.pages.flatMap((p) => p.items) ?? [],
[games.data],
);
// Auto-fetch subsequent pages when the sentinel at the bottom of the list
// scrolls into view — keeps the homepage a simple infinite scroll without
// a "Load more" button.
const sentinel = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinel.current;
if (!el || !games.hasNextPage) return;
const io = new IntersectionObserver(
(entries) => {
if (
entries.some((e) => e.isIntersecting) &&
!games.isFetchingNextPage
) {
void games.fetchNextPage();
}
},
{ rootMargin: '120px' },
);
io.observe(el);
return () => io.disconnect();
}, [games.hasNextPage, games.isFetchingNextPage, games.fetchNextPage]);
const startGame = async (player: string, genre: IGame['genre']) => {
if (!player.trim()) return;
setPending(genre);
try {
if (!savedGames.find((g) => g.playerName === player)) {
await saveGame.mutateAsync({ playerName: player, genre });
}
await navigate({
to: '/game/$playerName',
params: { playerName: player },
search: { genre },
});
} finally {
setPending(null);
}
};
const busy = pending !== null;
const firstLoad = games.isLoading;
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<div className="text-center">
<h1 className="bg-gradient-to-r from-amber-300 to-rose-400 bg-clip-text text-5xl font-bold text-transparent">
AI Dungeon Adventure
</h1>
<p className="text-muted-foreground mt-2">
Pick a hero name, choose a genre, begin.
</p>
</div>
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<Input
placeholder="Your hero's name"
value={playerName}
disabled={busy}
onChange={(e) => setPlayerName(e.target.value)}
/>
<div className="grid grid-cols-3 gap-3">
{GENRES.map((genre) => (
<Button
key={genre}
variant="secondary"
disabled={!playerName.trim() || busy}
onClick={() => startGame(playerName, genre)}
>
{pending === genre && <Spinner />}
{genre[0].toUpperCase() + genre.slice(1)}
</Button>
))}
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-xl font-semibold">
Continue
{(firstLoad || games.isFetching) && <Spinner className="size-4" />}
</h2>
{!firstLoad && savedGames.length === 0 && (
<p className="text-muted-foreground text-sm">
No saved games yet — start a new adventure above.
</p>
)}
{savedGames.map((g) => (
<Button
key={g.playerName}
variant="outline"
className="justify-between"
disabled={busy}
onClick={() => startGame(g.playerName, g.genre)}
>
<span>{g.playerName}</span>
<span className="text-muted-foreground text-sm">
{g.genre[0].toUpperCase() + g.genre.slice(1)}
</span>
</Button>
))}
<div ref={sentinel} aria-hidden className="h-1" />
{games.isFetchingNextPage && (
<div className="flex justify-center py-2">
<Spinner />
</div>
)}
</div>
</div>
);
}

This is the game picker: new-game form (shadcn Input + Button + Card) plus a “Continue” list fed by useGameApi().games.query with useInfiniteQuery — a bottom sentinel <div> watched by an IntersectionObserver auto-calls fetchNextPage() when scrolled into view, and spinners next to the heading and below the list surface the loading state. Starting a game saveGames the (playerName, genre) pair (so it shows up next time) and navigates to the play route.

Once saved, the dev server at http://localhost:4200/ should now let you start an adventure and chat with the Story Agent.

game-select.png
game-conversation.png

Your game is complete and you’ve tested every piece locally. Now let’s deploy it to AWS so you can play it from anywhere.

Terminal window
pnpm build
Terminal window
pnpm nx deploy infra "dungeon-adventure-infra-sandbox/*"

The deploy target uses CloudFormation express mode by default, which completes each resource operation as soon as its configuration is applied rather than waiting for full stabilization. Your first deployment will take around 4 minutes to complete, and some resources (such as the CloudFront distribution) may continue provisioning in the background for a short while afterwards. Subsequent deployments are faster.

Once the deployment completes, you will see outputs similar to the following:

Terminal window
dungeon-adventure-infra-sandbox-Application
dungeon-adventure-infra-sandbox-Application: deploying... [2/2]
dungeon-adventure-infra-sandbox-Application
Deployment time: 220s
⚠️ Stack deployed using Express Mode. Resources still stabilizing: GameUICloudfrontDistribution, UserIdentityWebAcl, ...and more...
Outputs:
dungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxx
dungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Navigate to your CloudFront URL (GameUIDistributionDomainName from the CDK outputs), sign up for a new account, and play your game running entirely on AWS!

Task 6: Mixing local and deployed components

Section titled “Task 6: Mixing local and deployed components”

You’ve seen two ends of the spectrum: everything local (dev) and everything deployed. During day-to-day development it’s often useful to mix the two — for example, iterate on website code against the real deployed API and agent, or run the API locally against the real DynamoDB table.

The key is the RUNTIME_CONFIG_APP_ID environment variable. When a project runs without LOCAL_DEV=true, the runtime config lookups fetch their configuration from AWS AppConfig using this application id — the RuntimeConfigApplicationId value from your CDK outputs.

Each website has a load:runtime-config target that downloads the deployed runtime-config.json (Cognito pool, API endpoints, agent ARN) into the local dev server. Some useful combinations:

  • Local website → deployed backend. Pull the deployed config once, then run the plain serve target so the UI talks to the deployed API and agent:

    Terminal window
    pnpm nx run game-ui:load:runtime-config
    Terminal window
    pnpm nx serve game-ui
  • Local API → real DynamoDB table. Run the plain serve target with RUNTIME_CONFIG_APP_ID set to the deployed application id:

    Terminal window
    RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> pnpm nx serve game-api

This flexibility lets you test exactly the slice you’re working on, against whichever components — local or deployed — make sense.

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