Contentful でゲーム仕様を構造化しておくと、クイズ Bot などへの再利用が簡単だった話

Contentful でゲーム仕様を構造化しておくと、クイズ Bot などへの再利用が簡単だった話

ゲームの仕様書を Contentful の Content Model として構造化しておくと、同じデータをそのままクイズ Bot などの周辺ツールに再利用できます。本記事では stageType や bossName などのフィールド設計と REST API のクエリ (content_type や select など) を組み合わせて、ボスが存在するバトルステージだけを四択クイズに変換する Next.js 製デモアプリを実装します。
2025.12.09

はじめに

本記事は SaaSで加速するゲーム開発 - Advent Calender 2025 - の 9 日目のブログです。

本記事では Contentful REST API と Next.js を組み合わせて、ゲームの「ステージ」の仕様書からクイズを自動生成する小さな Web アプリを作ったデモを紹介します。ゲームの仕様を Contentful 上で構造化しておくことで、追加の実装をあまり増やさずにクイズ Bot のようなアプリを素早く立ち上げられます。

contentful-quiz-bot-screenshot

Contentful とは

Contentful はヘッドレス CMS として、コンテンツを JSON 形式で管理し、 REST API や GraphQL API から取得できるサービスです。本記事では Content Delivery API を利用し、ゲームのステージ情報をクエリして Next.js 側でクイズデータに変換します。

Contentful を使う理由

ゲームの仕様書を単なるドキュメントではなく、 Contentful 上の構造化データとして持っておくことで、今回のようなクイズデモアプリを含むさまざまな周辺ツールに再利用しやすくなります。また、content_typefields.stageType select などのクエリパラメータを指定することで、アプリケーション側ではフィルタ処理を持たずに必要な情報だけを効率よく取得できます。

対象読者

  • ゲームの仕様書を CMS で管理したいと考えている方
  • Contentful をゲーム業界向けのユースケースで試してみたい方
  • Next.js と TypeScript で軽めのデモアプリを作りながら Contentful 連携を学びたい方

参考情報

全体構成

最初に、今回作成したデモアプリの構成を確認します。

  • Next.js アプリ

    • /api/quiz を呼び出してクイズを取得し、問題文と四択の選択肢、正誤判定結果をブラウザに表示します。
  • Next.js API Route

    • Contentful REST API から Stage エントリを取得し、ランダムな一問分のクイズデータを組み立てて JSON で返します。
  • Contentful

    • ステージ番号、ステージ名、ボス名、ステージ種別などの設定情報を Stage Content Model として管理します。
    • stageType でバトルステージか街ステージかなどを区別し、ボスが存在するステージだけをクイズ対象にできます。

Content Model の設計

まず、ゲームの「ステージ」仕様を Contentful 上で構造化データとして扱えるようにします。Stage という Content Model を作成し、ゲームの各ステージを一件のエントリとして登録します。

Stage Content Model

Stage content model

Stage Content Model には、次のようなフィールドを定義しました。

  • stageNumber (必須)

    • 種類: Integer
    • 説明: ステージ番号 (1, 2, 3 ...)
  • name (必須)

    • 種類: Short text
    • 説明: ステージ名
  • bossName

    • 種類: Short text
    • 説明: ボスの名前
    • チュートリアルステージや街エリアなど、ボスが存在しないステージもあるため必須にはしていません。
  • description

    • 種類: Long text
    • 説明: ステージの説明文や世界観のテキスト
  • stageType (必須)

    • 種類: Short text
    • 説明: ステージ種別を表すコード

stageType は Contentful の Predefined values を使って、次の三種類のみ入力できるようにしています。

  • battle
  • town
  • event

predefined value

これにより、入力時の表記揺れを防ぎながら、 API 側では fields.stageType=battle のような条件でバトルステージだけを厳密に絞り込めます。

Appearance を Dropdown に設定しておくと、モデル作成時の field 入力が簡単になります。

Appearance config

field 入力画面ではこのような見え方になります。

dropdown

クイズ対象の定義

今回のデモでは、クイズに出したいステージを次のように定義しました。

  • バトルステージであること

    • stageTypebattle である
  • ボスが存在すること

    • bossName が設定されている

このルールを Contentful のデータ側でしっかり表現しておくことで、アプリ側のクイズ生成ロジックは「バトルステージでボス名が入っているものだけを取ってくる」というシンプルなものにできます。

REST API によるデータ取得

クイズ生成に必要な情報だけを効率よく取得できるように、クエリパラメータを組み合わせます。

バトルステージだけを取得するクエリ

クイズに必要なのは、バトルステージでボスが存在するエントリだけです。そのため、 REST API 呼び出しでは次のような条件を付けています。

  • content_type=stage
    • Stage Content Model だけを対象にします。
  • fields.stageType=battle
    • バトルステージだけを取得します。
  • fields.bossName[exists]=true
    • bossName が設定されているエントリだけを対象にします。
  • select=...
    • stageNumber name bossName stageType など、クイズに必要なフィールドだけを選択します。

サンプルコード

src/lib/contentful.ts の抜粋
import type { ContentfulResponse, StageEntry } from "@/types/contentful";

function getContentfulConfig() {
  const spaceId = process.env.CONTENTFUL_SPACE_ID || "vw67dgbqxobj";
  const environmentId = process.env.CONTENTFUL_ENVIRONMENT_ID || "master";
  const accessToken = process.env.CONTENTFUL_CDA_TOKEN;

  if (!accessToken) {
    throw new Error("CONTENTFUL_CDA_TOKEN is not set");
  }

  return { spaceId, environmentId, accessToken };
}

export async function getBattleStages(): Promise<StageEntry[]> {
  const { spaceId, environmentId, accessToken } = getContentfulConfig();

  const params = new URLSearchParams({
    content_type: "stage",
    "fields.stageType": "battle",
    "fields.bossName[exists]": "true",
    select:
      "sys.id,fields.stageNumber,fields.name,fields.bossName,fields.stageType",
  });

  const url = `https://cdn.contentful.com/spaces/${spaceId}/environments/${environmentId}/entries?${params}`;

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
  });

  if (!response.ok) {
    throw new Error(
      `Failed to fetch stages from Contentful: ${response.status} ${response.statusText}`,
    );
  }

  const data: ContentfulResponse<StageEntry["fields"]> = await response.json();
  return data.items;
}

このように REST API 側の絞り込みをしっかり指定しておくと、アプリケーションで扱うデータを小さく保てます。 Contentful のコンテンツモデルとクエリパラメータを組み合わせて、この条件を満たすステージだけほしいという要件を素直に表現できる点が、今回のデモでの Contentful の大きな利点です。

クイズ API とフロントエンド実装

クイズ生成用 API Route

クイズ生成用の API は /api/quiz に実装しました。処理の流れは次のとおりです。

  1. 先ほどの条件で Stage エントリ一覧を取得します。
  2. 配列からランダムに一件を選び、そのステージを今回の問題とします。
  3. 他のステージから bossName を取り出し、誤答用の候補としてランダムに数件選びます。
  4. 正解と誤答候補をまとめてシャッフルし、選択肢配列と正解インデックスを計算します。
  5. stageNumber を使って 第 X 面のボスの名前はどれ という日本語の問題文を生成し、 JSON として返します。

サンプルコード

src/app/api/quiz/route.ts の抜粋
import { NextResponse } from "next/server";
import { getBattleStages } from "@/lib/contentful";
import type { Quiz } from "@/types/quiz";

function getRandomElement<T>(array: T[]): T {
  return array[Math.floor(Math.random() * array.length)];
}

function shuffle<T>(array: T[]): T[] {
  const shuffled = [...array];
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  return shuffled;
}

export async function GET() {
  const stages = await getBattleStages();

  if (stages.length < 3) {
    return NextResponse.json(
      { error: "クイズを生成するには最低3つのバトルステージが必要です" },
      { status: 400 },
    );
  }

  // 正解となるステージをランダムに 1 件選ぶ
  const correctStage = getRandomElement(stages);
  const correctBossName = correctStage.fields.bossName!;
  const stageNumber = correctStage.fields.stageNumber;

  // 誤答用のボス名を集める
  const otherStages = stages.filter(
    (stage) => stage.sys.id !== correctStage.sys.id,
  );
  const wrongChoices = shuffle(otherStages)
    .slice(0, 3)
    .map((stage) => stage.fields.bossName!);

  const choices = shuffle([correctBossName, ...wrongChoices]);
  const answerIndex = choices.indexOf(correctBossName);

  const quiz: Quiz = {
    stageNumber,
    questionText: `${stageNumber}面のボスの名前はどれ`,
    choices,
    answerIndex,
  };

  return NextResponse.json(quiz);
}

このようにしておくと、フロントエンド側からは単に /api/quiz を呼び出すだけで、一問分のクイズデータを取得できます。

クイズ画面の構成

フロントエンドは Next.js App Router のトップページに実装しています。画面には次の要素を配置しました。

  • クイズ未出題時の状態
    • クイズを出題ボタン

クイズ未出題時

  • 出題中の状態

    • 問題文
    • 四択の選択肢
    • 回答ボタン
  • 回答後の状態

    • 正解かどうかのメッセージ
    • もう一問ボタン

回答後

状態管理には React のフックを用い、クイズ未出題、クイズ表示中、回答済みの三つの状態を切り替えています。

サンプルコード

src/app/page.tsx の抜粋
"use client";

import { useState } from "react";
import type { Quiz } from "@/types/quiz";

type GameState = "idle" | "playing" | "answered";

export default function Home() {
  const [quiz, setQuiz] = useState<Quiz | null>(null);
  const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
  const [gameState, setGameState] = useState<GameState>("idle");

  const fetchQuiz = async () => {
    const response = await fetch("/api/quiz");
    if (!response.ok) {
      // エラーハンドリングは実装例を参照
      return;
    }
    const data: Quiz = await response.json();
    setQuiz(data);
    setSelectedChoice(null);
    setGameState("playing");
  };

  const handleSubmit = () => {
    if (selectedChoice === null) return;
    setGameState("answered");
  };

  const isCorrect =
    gameState === "answered" &&
    quiz &&
    selectedChoice !== null &&
    selectedChoice === quiz.answerIndex;

  return (
    <main>
      <button onClick={fetchQuiz} disabled={gameState === "playing"}>
        クイズを出題
      </button>

      {quiz && (
        <>
          <p>{quiz.questionText}</p>
          <ul>
            {quiz.choices.map((choice, index) => (
              <li key={choice}>
                <label>
                  <input
                    type="radio"
                    name="choice"
                    value={index}
                    checked={selectedChoice === index}
                    onChange={() => setSelectedChoice(index)}
                  />
                  {choice}
                </label>
              </li>
            ))}
          </ul>
          <button onClick={handleSubmit}>回答する</button>

          {gameState === "answered" && (
            <p>{isCorrect ? "正解です" : "残念、はずれです"}</p>
          )}
        </>
      )}
    </main>
  );
}

スタイルは CSS Modules で簡単なパネル風の見た目にしていますが、デモアプリの主役はあくまで Contentful のデータをクイズに変換する流れなので、スタイル部分の説明は最小限に留めています。

検証と考察

検証の流れ

  1. Contentful の管理画面から、 Stage エントリを複数作成します。
    • stageNumber name stageType を入力します。
    • クイズに出したいバトルステージには bossName を設定します。
      sample entry
  2. Next.js プロジェクトのルートで npm dev を実行し、ブラウザからトップページにアクセスします。
  3. 「クイズを出題」ボタンを押し、 Contentful 上のデータに応じたクイズが表示されることを確認します。

contentful-quiz-bot-screenshot

ステージを追加したい場合は、 Contentful に新しい Stage エントリを作成するだけで、クイズのバリエーションが増えます。ボス名の変更やステージ構成の変更も同様で、コードを書き換えずにクイズ内容を更新できます。

Contentful を使って良かった点

今回のデモを通して、 Contentful を使って良かったと感じた点をあらためて整理します。

  • ゲームの設定資料を Contentful に集約しておくことで、クイズアプリという別の形で即座に再利用できました。
  • stageTypebossName のようなフィールド設計をしておくことで、クイズ対象の定義をデータ側に寄せられ、アプリ側のロジックを薄く保てました。
  • REST API ではクエリパラメータで条件指定とフィールド選択ができるため、クイズ生成に必要な情報だけを効率よく取得できました。

ゲーム開発の現場では、仕様検討やバランス調整のためにステージ設定の一覧が必要になることが多いと思います。そうした情報を Contentful 上で一元管理しておけば、今回のようなクイズデモだけでなく、内部向けの確認ツールやデバッグ用ビューなどにも簡単に展開できます。

まとめ

本記事では Contentful に保存したゲームの「ステージ」仕様から、 Next.js を使って四択クイズを自動生成するデモアプリを紹介しました。 Stage にステージ番号やボス名、ステージ種別を定義し、 REST API のクエリパラメータでバトルステージだけを抽出することで、クイズ生成ロジックをシンプルに保つことができました。

ゲームの仕様書を Contentful のようなヘッドレス CMS に載せておくと、今回のようなクイズアプリを含む周辺ツールを後から増やしやすくなります。

この記事をシェアする

FacebookHatena blogX

関連記事