4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェント開発初心者向けに、Next.js x Mastra x AG-UI でAIオセロを作ってみた

4
Last updated at Posted at 2025-12-07

こんにちは、karamageです

AIエージェント開発初心者向けに、AIと一緒にオセロができるWebアプリを作ってみました。
チャットで「次どこに置けばいい」って聞いたら教えてくれたり、「代わりに打って」ってお願いしたら実際に石を置いてくれるアプリです。

これは、初心者がAIエージェント開発を学ぶためにちょうどよい題材 ではないでしょうか。

「AIエージェント開発」って聞いても難しいそうでピンと来なかったんですが、作ってみたら思ったより簡単でした。

この記事では、これから AIエージェント開発を始めたいって人向け に、実際のAIオセロアプリのコードを見ながら解説します。

AIエージェント開発を始めてみたい人の参考になれば幸いです。

この記事で解説するコードは、以下においてあります。

完成品はこんな感じです

まず、完成したものがこちら。

  • 画面左側にオセロ盤面
  • 画面右側にチャット欄
  • チャットで「最適な手を教えて」と入力すると、AIが盤面を分析して「(3, 2)に置くのがおすすめです」みたいに答えてくれる
  • 「代わりに打って」と言えば、AIが実際に石を置いてくれる
  • もちろん、普通にクリックして遊ぶこともできる

Screenshot 2025-12-07 at 15.32.59.png

使った技術スタック

このプロジェクトでは、3つのメイン技術を組み合わせています。

Next.js

Next.jsは、言わずとしれたReactベースのフレームワークです。

特にNext.js は フロントとバックエンド両方作れるのが便利で

  • フロントエンド(ブラウザに表示されるUI): components/ フォルダにReactコンポーネントを置く
  • バックエンド(サーバー側の処理): app/api/ フォルダにroute.tsファイルを置くだけでAPIが作れる (その他Server ComponentやServer Action等いろいろある)

つまり、同じプロジェクト内でフロントもバックエンドも両方書くことができます
フロントとバックエンドを別々のプロジェクトにしなくていいし、TypeScriptの型定義も共有できる。

// app/api/mastra/route.ts - これでAPIエンドポイントができる
export async function POST(req: NextRequest) {
  const body = await req.json();
  // 処理...
  return NextResponse.json({ success: true });
}

フロントエンド側からは、こんな感じで呼び出せます

const response = await fetch('/api/mastra', {
  method: 'POST',
  body: JSON.stringify({ message: 'こんにちは' })
});

Mastra - サーバーサイドのAIエージェント開発フレームワーク

Mastraは、AIエージェントを作るためのフレームワークです。「エージェント」っていうのは、ざっくり言うと「自分で考えて行動できるAI」のことです。

普通のAIチャットボットは「質問に答える」だけですが、エージェントは 「ツール」を使って実際にアクションを起こせる ことができたりします。(他にもワークフロー、RAG、ストレージ、メモリ、評価、etcなどのいろいろな機能がある)

今回のオセロアプリでは、こんなツールを作りました

// lib/mastra.ts
export const placeDiscTool = createTool({
  id: 'place-disc',
  description: 'オセロの盤面に石を置く',
  inputSchema: z.object({
    row: z.number().min(0).max(7),
    col: z.number().min(0).max(7),
  }),
  execute: async ({ context }) => {
    const { row, col } = context;
    return {
      action: 'place-disc',
      position: { row, col },
      message: `(${col}, ${row})に石を置きます`
    };
  },
});

このツールをエージェントに渡すと、AIが「あ、ユーザーが『石を置いて』って言ってる。place-discツールを使えばいいんだな」って判断して、自動的にツールを呼び出してくれます。

エージェント自体はこんな感じで定義します

export const chatAgent = new Agent({
  name: 'othello-assistant',
  instructions: `あなたはオセロゲームのAIアシスタントです。

1. ユーザーが「手を打って」と言ったら、place-discツールを使って実際に手を打つ
2. 「次の手を教えて」と言われたら、suggest-best-moveツールで提案する
3. 角を取る、多くの石を返すなどの戦略を考慮する`,
  model: 'openai/gpt-4o-mini',
  tools: {
    'place-disc': placeDiscTool,
    'suggest-best-move': suggestBestMoveTool,
  },
});

instructionsで「エージェントの役割」を日本語で説明しておけば、AIがそれに従って行動してくれます。従来のプログラムっぽくないですよね。まるで人に仕事を頼むみたいに自然言語で書くのがAIエージェント開発の面白いところです。

CopilotKit - チャットUIとAIアクション

CopilotKitは、WebアプリにAIチャット機能を簡単に追加できるライブラリです。「AG-UI」というプロトコルを使用しています(Agent UIの略なんですかね?)。

CopilotKitの便利なところは、「AIに状態を見せる」と「AIにアクションさせる」が超簡単にできることです。

useCopilotReadable - AIに状態を見せる

// components/OthelloGame.tsx
const [board, setBoard] = useState<Board>(createEmptyBoard());
const [currentPlayer, setCurrentPlayer] = useState<Player>('black');

// これだけで、AIに現在のゲーム状態が見える!
useCopilotReadable({
  description: 'オセロゲームの現在の状態',
  value: {
    board,
    currentPlayer,
    validMoves: getValidMoves(board, currentPlayer),
    pieceCount: countPieces(board),
  },
});

これを書いておくと、AIが「今、盤面はこうなってて、黒の番で、置ける場所はここで...」って理解した状態で返事をしてくれます。

useCopilotAction - AIにアクションさせる

useCopilotAction({
  name: 'playBestMove',
  description: 'AIが最適な手を計算して実際に盤面に石を置く',
  parameters: [
    {
      name: 'difficulty',
      type: 'number',
      description: '難易度(1-5)',
      required: false,
    },
  ],
  handler: async ({ difficulty = 3 }) => {
    const bestMove = findBestMove(board, 'white', difficulty);
    if (bestMove) {
      placePiece(bestMove[0], bestMove[1], 'white');
      return `(${bestMove[1]}, ${bestMove[0]})に白の石を置きました!`;
    }
    return '有効な手がありません';
  },
});

ユーザーがチャットで「最適な手を打って」みたいに言うと、CopilotKitが自動的にplayBestMoveアクションを呼び出して、handler関数が実行されます。

なぜこの構成が良いのか - フロントエンドとバックエンドの統一開発

ここまで読んで「で、なんでこの3つを組み合わせたの?」って思いますよね。

このオセロアプリ、フロントエンドもバックエンドも全部TypeScriptで統一されてるんです。しかも、同じリポジトリで管理できる。これが個人的にめちゃくちゃ開発しやすいのでおすすめです。

従来の方法だと...

昔ながらの開発だと、こんな感じになります

フロントエンド (React)
  ↕ HTTP ↕
バックエンド (Python/Flaskとか)
  ↕
AIサービス (OpenAI API)
  • フロントとバックで言語が違う(JavaScript vs Python)
  • プロジェクトも別々
  • 型定義が共有できない
  • データのやり取りでミスりやすい

今回の構成

Next.js (TypeScript)
├─ フロントエンド (components/)
│   └─ CopilotKit (チャットUI + アクション)
└─ バックエンド (app/api/)
    ├─ Mastra (AIエージェント)
    └─ OpenAI API

全部TypeScriptで書ける! しかも同じプロジェクト内!

例えば、ゲームの型定義を見てください

// lib/game/othello.ts
export type Cell = 'black' | 'white' | null;
export type Board = Cell[][];
export type Player = 'black' | 'white';

この型定義を、フロントエンド側でもバックエンド側でも、まったく同じように使えます

// components/OthelloGame.tsx (フロント)
const [board, setBoard] = useState<Board>(createEmptyBoard());

// app/api/mastra/route.ts (バックエンド)
const { board } = await req.json(); // 型安全!

型定義が共通で使えるので便利。

プロジェクト構成

実際のディレクトリ構成はこんな感じです

ai-othello/
├── app/
│   ├── api/
│   │   ├── copilotkit/route.ts  # CopilotKitのエンドポイント
│   │   └── mastra/route.ts      # Mastraエージェントのエンドポイント
│   ├── layout.tsx               # 全体のレイアウト
│   └── page.tsx                 # トップページ
├── components/
│   ├── OthelloGame.tsx          # オセロ盤面 + CopilotKit統合
│   └── OthelloWithChat.tsx      # 盤面とチャットのコンテナ
└── lib/
    ├── mastra.ts                # Mastraエージェント設定
    └── game/
        └── othello.ts           # オセロのゲームロジック

app/ - Next.jsのApp Router

Next.js の「App Router」は、ファイル名ベースでルーティングが決まる。

  • app/page.tsx/ (トップページ)
  • app/api/mastra/route.ts/api/mastra (APIエンドポイント)

このほうがめっちゃシンプル。

components/ - UIコンポーネント

Reactコンポーネントを置く場所です。

OthelloWithChat.tsx - CopilotKitのラッパー

'use client';

export default function OthelloWithChat() {
  return (
    <CopilotKit runtimeUrl="/api/copilotkit">
      <CopilotSidebar
        labels={{
          title: 'オセロAIアシスタント',
          initial: 'こんにちは!オセロゲームのアシスタントです。',
        }}
      >
        <OthelloGame />
      </CopilotSidebar>
    </CopilotKit>
  );
}

CopilotSidebarで囲むだけで、右側にチャット欄が出現します。簡単。

OthelloGame.tsx - ゲーム本体

ここがメインのゲームロジックです。useState でゲーム状態を管理して、useCopilotReadable で状態をAIに公開、useCopilotAction でAIからのアクションを受け付けています。

lib/ - ビジネスロジック

lib/game/othello.ts - 純粋なゲームロジック

オセロのルール、勝敗判定、AI(Minimax法)などが入ってます。ここはReactやNext.jsに一切依存しない、純粋なTypeScriptコードです。

// 空のボードを作成
export function createEmptyBoard(): Board {
  const board: Board = Array(8).fill(null).map(() => Array(8).fill(null));
  board[3][3] = 'white';
  board[3][4] = 'black';
  board[4][3] = 'black';
  board[4][4] = 'white';
  return board;
}

// 有効な手を取得
export function getValidMoves(board: Board, player: Player): [number, number][] {
  const moves: [number, number][] = [];
  for (let row = 0; row < 8; row++) {
    for (let col = 0; col < 8; col++) {
      if (isValidMove(board, row, col, player)) {
        moves.push([row, col]);
      }
    }
  }
  return moves;
}

ゲームロジックを分離しておくことで、テストも書きやすいし、他のプロジェクトで再利用もできます。

lib/mastra.ts - Mastraエージェント定義

ここでツールとエージェントを定義しています。

// ツール定義
export const placeDiscTool = createTool({
  id: 'place-disc',
  description: 'オセロの盤面に石を置く',
  inputSchema: z.object({
    row: z.number().min(0).max(7),
    col: z.number().min(0).max(7),
  }),
  execute: async ({ context }) => {
    // 実際の処理...
  },
});

export const suggestBestMoveTool = createTool({
  id: 'suggest-best-move',
  description: 'オセロの現在の盤面を分析して、最適な手を提案する',
  inputSchema: z.object({
    board: z.array(z.array(z.union([z.literal('black'), z.literal('white'), z.null()]))),
    player: z.enum(['black', 'white']),
  }),
  execute: async ({ context }) => {
    const { board, player } = context;
    const bestMove = findBestMove(board, player);
    // ...
  },
});

// エージェント定義
export const chatAgent = new Agent({
  name: 'othello-assistant',
  instructions: `あなたはオセロゲームのAIアシスタントです...`,
  model: 'openai/gpt-4o-mini',
  tools: {
    'place-disc': placeDiscTool,
    'suggest-best-move': suggestBestMoveTool,
  },
});

Zodという型検証ライブラリ(z.object(...))を使って、ツールの入力を型安全にしています。

データフロー

実際にユーザーが「最適な手を打って」とチャットで送信したとき、何が起こるのか追ってみましょう

パターン1: CopilotKitアクション経由

1. ユーザー「最適な手を打って」
   ↓
2. CopilotKit → OpenAI APIに送信
   ↓
3. OpenAI「useCopilotActionで定義されてる`playBestMove`を呼ぶべきだな」
   ↓
4. playBestMoveのhandler関数が実行される
   ↓
5. findBestMove(board, 'white', 3) でAIが最適手を計算
   ↓
6. placePiece(row, col, 'white') で石を配置
   ↓
7. setBoard(newBoard) でReactの状態更新
   ↓
8. 画面が再レンダリングされて石が表示される

というようなフローになります。

パターン2: Mastraエージェント経由

1. ユーザー「最適な手を教えて」
   ↓
2. チャット送信 → POST /api/mastra
   ↓
3. Mastraエージェントが起動
   ↓
4. エージェント「suggest-best-moveツールを使おう」
   ↓
5. suggestBestMoveTool.execute() が実行
   ↓
6. findBestMove(board, player) で最適手を計算
   ↓
7. 結果をJSON形式で返す
   ↓
8. フロントエンドで結果を表示

こっちはMastraエージェントはサーバーで動くので、より複雑な処理や、データベースアクセスなども可能です。

コアな部分を深掘り

CopilotKitの魔法 - useCopilotAction

useCopilotActionがどういう仕組みで動いてるか、ちょっと深掘り。

useCopilotAction({
  name: 'playBestMove',
  description: 'AIが最適な手を計算して実際に盤面に石を置く',
  parameters: [
    {
      name: 'difficulty',
      type: 'number',
      description: '難易度(1-5)',
      required: false,
    },
  ],
  handler: async ({ difficulty = 3 }) => {
    // 処理...
  },
});

実は、CopilotKitはこの情報をOpenAIの「Function Calling」機能に変換してるんです。

OpenAIのFunction Callingって、こんな感じでAIに「使える関数」を教えることができる機能です

{
  "name": "playBestMove",
  "description": "AIが最適な手を計算して実際に盤面に石を置く",
  "parameters": {
    "type": "object",
    "properties": {
      "difficulty": {
        "type": "number",
        "description": "難易度(1-5)"
      }
    }
  }
}

AIがチャット内容を見て「あ、これplayBestMoveを呼ぶべきだな」と判断したら、こんなJSONを返してきます

{
  "function_call": {
    "name": "playBestMove",
    "arguments": "{\"difficulty\": 3}"
  }
}

CopilotKitは、このJSONを受け取ったら自動的にhandler関数を実行してくれるわけです。賢い。

Mastraのツール - 何ができるの?

Mastraのツールは、もっと柔軟です。今回は「石を置く」「最適手を提案する」という2つのツールを作りましたが、実際にはなんでもできます:

  • データベースから情報を取得する
  • 外部APIを呼び出す
  • ファイルを読み書きする
  • メールを送る
  • etc...

例えば、オセロの棋譜をデータベースに保存するツールを追加するなら

export const saveGameTool = createTool({
  id: 'save-game',
  description: 'オセロの棋譜をデータベースに保存する',
  inputSchema: z.object({
    board: z.array(z.array(z.union([z.literal('black'), z.literal('white'), z.null()]))),
    moves: z.array(z.object({
      player: z.enum(['black', 'white']),
      row: z.number(),
      col: z.number(),
    })),
  }),
  execute: async ({ context }) => {
    const { board, moves } = context;
    // データベースに保存...
    await db.games.insert({ board, moves, createdAt: new Date() });
    return { success: true, message: '棋譜を保存しました' };
  },
});

こんな感じで、エージェントに「新しい能力」をどんどん追加できます。まるでRPGのスキルツリーみたいですね。

オセロAIのアルゴリズム - Minimax法

オセロのAIには「Minimax法」という古典的なアルゴリズムを使っています。簡単に説明すると

  1. 「自分が打てる全ての手」を試してみる
  2. それぞれの手について、「相手の最善手」を予測する
  3. 相手も同じように「自分の最善手」を予測する
  4. これを何手か先まで繰り返す
  5. 最終的に一番有利になる手を選ぶ
export function findBestMove(
  board: Board,
  player: Player,
  depth: number = 3
): [number, number] | null {
  const validMoves = getValidMoves(board, player);

  let bestMove: [number, number] = validMoves[0];
  let bestScore = -Infinity;

  for (const [row, col] of validMoves) {
    const newBoard = makeMove(board, row, col, player);
    const score = minimax(newBoard, depth - 1, false, player, -Infinity, Infinity);

    if (score > bestScore) {
      bestScore = score;
      bestMove = [row, col];
    }
  }

  return bestMove;
}

depthが深いほど先まで読みますが、計算時間もかかります。今回はdepth=3(3手先まで読む)にしました。

盤面の評価は、こんな要素を見ています

export function evaluateBoard(board: Board, player: Player): number {
  let score = counts[player] - counts[opponent]; // 石の数の差

  // 角のボーナス(角は超重要!)
  const corners = [[0, 0], [0, 7], [7, 0], [7, 7]];
  for (const [row, col] of corners) {
    if (board[row][col] === player) score += 25;
    if (board[row][col] === opponent) score -= 25;
  }

  // モビリティ(打てる手の数)
  const playerMoves = getValidMoves(board, player).length;
  const opponentMoves = getValidMoves(board, opponent).length;
  score += (playerMoves - opponentMoves) * 2;

  return score;
}

オセロは「角を取る」のが超重要なので、角のボーナスを+25にしています。あと、「打てる手が多い方が有利」なので、モビリティも評価に入れてます。

実装のポイント

1. 状態管理はシンプルに

今回、状態管理ライブラリ(ReduxとかZustandとか)は使っていません。useState だけで十分です。

const [board, setBoard] = useState<Board>(createEmptyBoard());
const [currentPlayer, setCurrentPlayer] = useState<Player>('black');
const [gameOver, setGameOver] = useState(false);

無理に複雑なことをせず、Reactの基本機能だけでシンプルに保つのがコツ。

2. 型の恩恵を受ける

TypeScriptの型定義をちゃんと書いておくと、めっちゃ開発が楽になります。

export type Cell = 'black' | 'white' | null;
export type Player = 'black' | 'white';

こうしておくと、player = 'red'とか書いたら即エラーになります。バグを未然に防げるわけですね。

3. 関数を小さく保つ

lib/game/othello.tsを見ると、一つ一つの関数が小さいことに気づくと思います。

  • createEmptyBoard() - ボードを作る
  • isValidMove() - その手が有効かチェック
  • makeMove() - 手を打つ
  • getValidMoves() - 打てる手を全部取得
  • countPieces() - 石を数える

それぞれの関数が「一つのことだけ」をやってるので、読みやすいし、テストも書きやすいです。

4. UI/ロジックの分離

lib/game/othello.tsには、Reactや Next.jsに依存するコードが一切ありません。純粋なTypeScriptです。

こうしておくと:

  • テストが書きやすい
  • 他のプロジェクトで再利用できる
  • UIを変えてもロジックは変えなくていい

動かしてみよう

実際にプロジェクトを動かすのは簡単です。

1. リポジトリをクローン

git clone git@github.com:karamage/ai-othello.git
cd ai-othello

2. 依存関係をインストール

npm install

3. OpenAI APIキーを設定

.env.localファイルを作成して、APIキーを書きます

OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx

OpenAI APIキーは、OpenAIのサイトで取得できます。

4. 開発サーバー起動

npm run dev

ブラウザで http://localhost:3000 を開くと、オセロアプリが立ち上がります!

5. 遊んでみる

  • 黒(あなた)の石をクリックして置く
  • チャットで「最適な手を教えて」と聞いてみる
  • 「代わりに打って」と頼んでみる

色々試してみてください!

ハマったポイント

開発中、いくつかハマったところがあるので、書き残します。

1. CopilotKitとMastra、どっちを使うべき?

最初、「CopilotKitとMastra、両方あるけど、どう使い分けるの?」って混乱しました。

結論:

  • クライアントサイドで完結する処理 → CopilotKit

    • ゲーム状態の読み取り
    • 簡単なアクション(リセット、石を置くなど)
  • サーバーサイドでやりたい処理 → Mastra

    • データベースアクセス
    • 外部API呼び出し
    • 複雑な計算(重い処理)

今回のオセロだと、実はCopilotKitだけでも完結できます。でも、Mastraも使うことで「サーバーサイドエージェント」の使い方を学べるので、両方入れてみました。

2. 座標系の混乱

オセロの盤面、board[row][col]って書いてますが、表示するときは(col, row)の順番で表示してます(チェスの記法に合わせて)。

これがちょいちょい混乱の元でした。コメントをちゃんと書いて、どっちがどっちか明確にするのが大事ですね。

// 重要: 盤面は board[row][col] だけど、表示は (col, row)
return `(${bestMove[1]}, ${bestMove[0]})に白の石を置きました!`;

3. パスの処理

オセロって、「打つ手がない場合はパス」っていうルールがあるんですが、これの実装が意外と面倒でした。

// 次のプレイヤーが打てる手があるかチェック
if (nextValidMoves.length === 0) {
  // パス: 元のプレイヤーの番に戻る
  const originalValidMoves = getValidMoves(newBoard, player);
  if (originalValidMoves.length === 0) {
    // 両者とも打てない = ゲーム終了
    setGameOver(true);
  }
  return { success: true, message: `パスです`, pass: true };
}

「両方のプレイヤーが打てないか?」をちゃんとチェックしないと、ゲームが終わらなくなります。

改善アイデア

このプロジェクト、まだまだ改善の余地があります。自分で遊びながら思いついたアイデアをいくつか:

1. 棋譜の保存・再生

「今までの手順を見返せたらいいな」って思いました。Mastraのツールで実装できそう

const saveMovesTool = createTool({
  id: 'save-moves',
  description: '棋譜を保存する',
  // ...
});

2. 難易度調整

今はdepth=3固定ですが、チャットで「難易度を上げて」とか言ったらdepthを変えるとか、面白そうです。

3. 対戦履歴の分析

「あなたは角を取るのが得意ですね」みたいな分析をAIにさせるのも面白そう。Mastraのツールでゲーム履歴を読み取って、GPT-4に分析させるとか。

4. マルチプレイヤー

WebSocketを使って、オンライン対戦できるようにするとか。Next.jsでもWebSocket使えるので、やってみたいですね。

まとめ - AIエージェント開発の第一歩として

このオセロアプリ、初心者がAIエージェント開発を学ぶのにちょうどいい教材だと思います。

学べること

  1. Next.js App Router - フロント・バックエンド統一開発
  2. CopilotKit - クライアントサイドAI統合
  3. Mastra - サーバーサイドエージェント
  4. TypeScript - 型安全な開発
  5. AI Function Calling - AIにツールを使わせる

難しすぎない理由:

  • ゲームのルールがシンプル
  • 状態管理が複雑じゃない
  • データベース不要(全部メモリ内)
  • デプロイも簡単(Vercelにポンっと置くだけ)

発展性がある:

  • 他のゲーム(チェス、将棋、etc)に応用できる
  • データベース追加で本格的なアプリに
  • マルチプレイヤー機能追加
  • AI分析機能追加

AIエージェント開発、思ったより簡単だと思いませんか?

昔は「AIアプリを作る」って言ったら、Python でゴリゴリ書いて、モデルを学習させて...みたいな感じでしたが、今は「各種LLMモデル APIを叩くだけ」で結構すごいことができちゃいます。

しかもNext.js + TypeScriptで統一的に書けるので、フロントエンドエンジニアでも十分手が出せる。良い時代になりました。

この記事が、あなたのAIエージェント開発の第一歩になれば嬉しいです。

参考リンク

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?