UIの構築
タスク1: すべてをローカルで実行
Section titled “タスク1: すべてをローカルで実行”フルローカルスタック — game-ui開発サーバー、ローカルGame API、ローカルAG-UI Story Agent(これがInventory MCPサーバーを起動します)— を1つのコマンドで起動します:
pnpm nx serve-local game-uiyarn nx serve-local game-uinpx nx serve-local game-uibunx nx serve-local game-uigame-ui:serve-localはgame-api:serve-localとdungeon_adventure.story:agent-serve-localにdependsOnしているため、Nxは各プロジェクトのローカルサーバーを並行して起動します。ブラウザで開発サーバーを開き、新規ユーザー作成のプロンプトに従います。
タスク2: CopilotKitが既に配線されている場所
Section titled “タスク2: CopilotKitが既に配線されている場所”モジュール1でgame-ui → storyのconnectionジェネレータを実行したとき、ShadcnウェブサイトのAG-UI統合が自動生成されました。簡単に確認してみましょう:
Directorypackages/game-ui/src/
Directorycomponents/
- AguiProvider.tsx すべてのAG-UIエージェントに登録された単一の
CopilotKitProvider。 Directorycopilot/
- index.tsx テーマ化された
CopilotChat/CopilotSidebar/CopilotPopupを再エクスポート。 - ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
- index.tsx テーマ化された
- AguiProvider.tsx すべてのAG-UIエージェントに登録された単一の
Directoryhooks/
- useAguiStoryAgent.tsx デプロイされたStory Agentを指す
@ag-ui/clientのHttpAgentをインスタンス化し、threadIdをAgentCoreの最小セッションID長である33文字にパディング。
- useAguiStoryAgent.tsx デプロイされたStory Agentを指す
- main.tsx
<App />を<AguiProvider>でラップ
必要なのは、ルートに<CopilotChat agentId="agent" threadId={...} />を配置するだけです。統合の詳細については、React → AG-UI接続ガイドを参照してください。
タスク3: ダンジョン用のスタイル変更
Section titled “タスク3: ダンジョン用のスタイル変更”packages/game-ui/src/styles.cssを置き換えます。これはスタイリングのために変更する唯一のファイルです。共有のShadcnグローバルをインポートし、パレットを松明で照らされたダンジョンテーマに上書きし、CopilotKitがそれらの色を継承するようにします:
@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;}タスク4: ゲームルートの作成
Section titled “タスク4: ゲームルートの作成”2つのルートが必要です。1つはヒーローを選ぶため、もう1つはプレイするためです。どちらもshadcnコンポーネントとCopilotKitチャットを使用します。手作りのチャット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> );}import { createFileRoute } from '@tanstack/react-router';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';
export const Route = createFileRoute('/')({ component: RouteComponent,});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="text-center"> <header> <h1>Welcome</h1> <p>Welcome to your new React website!</p> </header> <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> );}これはゲームピッカーです: 新規ゲームフォーム(shadcnのInput + Button + Card)と、useInfiniteQueryを使用したuseGameApi().games.queryによって供給される「続きから」リスト — 下部のセンチネル<div>がIntersectionObserverによって監視され、ビューにスクロールされると自動的にfetchNextPage()を呼び出し、見出しの横とリストの下のスピナーがローディング状態を表示します。ゲームを開始すると(playerName, genre)ペアをsaveGameし(次回表示されるように)、プレイルートに移動します。
import { UseAgentUpdate, useAgent } from '@copilotkit/react-core/v2';import { useQuery } from '@tanstack/react-query';import { createFileRoute } from '@tanstack/react-router';import { useEffect, useMemo, useRef } from 'react';import { CopilotChat } from '../../components/copilot';import { useGameApi } from '../../hooks/useGameApi';import type { IGame } from ':dungeon-adventure/game-api';
// AgentCore session ids must be at least 33 characters. The AG-UI hook pads// the threadId to this length before sending, so the thread id is stable for// a given (player, genre) pair — revisiting the URL continues the same story.const buildThreadId = (playerName: string, genre: string) => `${playerName}-${genre}`.padEnd(33, '0');
export const Route = createFileRoute('/game/$playerName')({ component: RouteComponent, validateSearch: (search: Record<string, unknown>) => ({ genre: search.genre as IGame['genre'], }),});
function RouteComponent() { const { playerName } = Route.useParams(); const { genre } = Route.useSearch(); const threadId = useMemo( () => buildThreadId(playerName, genre), [playerName, genre], );
const gameApi = useGameApi(); const inventory = useQuery( gameApi.inventory.query.queryOptions({ playerName, limit: 100 }), ); // Conversation history persisted by the agent's ``S3SessionManager``. Each // turn is stored as ``session_<threadId>/agents/agent_default/messages/…``. // // `staleTime: 0` + `refetchOnMount: 'always'` together force a fresh read // on every visit — the cached snapshot from the *first* time we loaded // this route (before the agent had written any turns back to S3) would // otherwise look like an empty thread on revisit and trigger re-priming. const pastActions = useQuery({ ...gameApi.actions.query.queryOptions({ sessionId: threadId }), staleTime: 0, refetchOnMount: 'always', });
const { agent } = useAgent({ agentId: 'agent', updates: [UseAgentUpdate.OnMessagesChanged], });
// Hydrate the chat once the history query resolves. For a fresh thread // (no stored messages) the agent's system prompt expects the player's // name and genre in the first user message, so send that priming line. // // We wait for `isFetching` to go false (rather than just `isLoading`) so // that revisits with a cached empty result from the first visit aren't // mistaken for a fresh thread — the background refetch is what sees the // turns the agent wrote since. const primedRef = useRef(false); useEffect(() => { if (!agent || primedRef.current) return; if (pastActions.isFetching || !pastActions.isSuccess) return; primedRef.current = true; const items = pastActions.data.items; if (items.length > 0) { agent.setMessages( items.map((a) => ({ id: `m-${a.messageId}`, role: a.role, content: a.content, })), ); return; } agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: `My name is ${playerName}. Start my ${genre} adventure.`, }); void agent.runAgent(); }, [ agent, pastActions.data, pastActions.isFetching, pastActions.isSuccess, playerName, genre, ]);
// The agent's ``add-to-inventory`` tool calls mutate DynamoDB directly, so // the inventory query needs a nudge to refetch as turns complete. The // ``useAgent({ updates: [OnMessagesChanged] })`` subscription re-renders // this route on each message event — refetch whenever the message count // changes, which covers both the initial populate and every subsequent // turn. const seenMessages = useRef(0); useEffect(() => { if (!agent) return; if (agent.messages.length !== seenMessages.current) { seenMessages.current = agent.messages.length; void inventory.refetch(); } });
return ( <div className="relative flex h-[calc(100vh-10rem)] min-h-0 flex-col"> {!!inventory.data?.items.length && ( <aside className="bg-accent text-accent-foreground pointer-events-none absolute right-4 top-4 z-10 w-56 rounded-lg border p-3 shadow-lg"> <div className="mb-1 font-semibold">📦 Inventory</div> <ul className="flex flex-col gap-0.5 text-sm"> {inventory.data.items.map((item) => ( <li key={item.itemName}> {item.emoji ?? '•'} {item.itemName} {item.quantity > 1 ? ` (x${item.quantity})` : ''} </li> ))} </ul> </aside> )} <CopilotChat agentId="agent" threadId={threadId} labels={{ chatInputPlaceholder: 'What do you do?', welcomeMessageText: `${playerName}'s ${genre} adventure`, }} /> </div> );}これはプレイルートです。決定論的なthreadId({player}-{genre}を33文字にパディング — AG-UIフックはこれをそのままAgentCoreセッションIDとして送信)を構築し、<CopilotChat agentId="agent" threadId={threadId} />をレンダリングし、useGameApi().inventory.queryからのインベントリを上にオーバーレイします。マウント時に、useGameApi().actions.query({ sessionId: threadId })がエージェントがS3に保存した会話履歴を読み取り — 履歴がある場合は — agent.setMessages(...)を呼び出してチャットを再ハイドレートします。履歴がない場合は、ストーリーを開始するための1つのプライミングユーザーメッセージを送信します。agent.messagesはuseAgent({ updates: [OnMessagesChanged] })を介してサブスクライブされているため、新しいターンごとにインベントリクエリも再フェッチされます(MCPツール呼び出しはDynamoDBを直接変更します)。
保存すると、http://localhost:4200/の開発サーバーで冒険を開始し、Story Agentとチャットできるようになります。
ビルドとデプロイ
タスク5: ビルドとデプロイ
Section titled “タスク5: ビルドとデプロイ”コードのビルド
Section titled “コードのビルド”pnpm buildyarn buildnpm run buildbun buildアプリケーションのデプロイ
Section titled “アプリケーションのデプロイ”pnpm nx deploy infra dungeon-adventure-infra-sandbox/*yarn nx deploy infra dungeon-adventure-infra-sandbox/*npx nx deploy infra dungeon-adventure-infra-sandbox/*bunx nx deploy infra dungeon-adventure-infra-sandbox/*デプロイ後、CloudFront URL(CDK出力のGameUIDistributionDomainName)に移動します。

おめでとうございます。エージェント型ダンジョンアドベンチャーゲームの構築とデプロイが完了しました! 🎉🎉🎉