Contentful でゲーム仕様を構造化しておくと、クイズ Bot などへの再利用が簡単だった話
はじめに
本記事は SaaSで加速するゲーム開発 - Advent Calender 2025 - の 9 日目のブログです。
本記事では Contentful REST API と Next.js を組み合わせて、ゲームの「ステージ」の仕様書からクイズを自動生成する小さな Web アプリを作ったデモを紹介します。ゲームの仕様を Contentful 上で構造化しておくことで、追加の実装をあまり増やさずにクイズ Bot のようなアプリを素早く立ち上げられます。

Contentful とは
Contentful はヘッドレス CMS として、コンテンツを JSON 形式で管理し、 REST API や GraphQL API から取得できるサービスです。本記事では Content Delivery API を利用し、ゲームのステージ情報をクエリして Next.js 側でクイズデータに変換します。
Contentful を使う理由
ゲームの仕様書を単なるドキュメントではなく、 Contentful 上の構造化データとして持っておくことで、今回のようなクイズデモアプリを含むさまざまな周辺ツールに再利用しやすくなります。また、content_type や fields.stageType select などのクエリパラメータを指定することで、アプリケーション側ではフィルタ処理を持たずに必要な情報だけを効率よく取得できます。
対象読者
- ゲームの仕様書を CMS で管理したいと考えている方
- Contentful をゲーム業界向けのユースケースで試してみたい方
- Next.js と TypeScript で軽めのデモアプリを作りながら Contentful 連携を学びたい方
参考情報
全体構成
最初に、今回作成したデモアプリの構成を確認します。
-
Next.js アプリ
/api/quizを呼び出してクイズを取得し、問題文と四択の選択肢、正誤判定結果をブラウザに表示します。
-
Next.js API Route
- Contentful REST API から
Stageエントリを取得し、ランダムな一問分のクイズデータを組み立てて JSON で返します。
- Contentful REST API から
-
Contentful
- ステージ番号、ステージ名、ボス名、ステージ種別などの設定情報を
StageContent Model として管理します。 stageTypeでバトルステージか街ステージかなどを区別し、ボスが存在するステージだけをクイズ対象にできます。
- ステージ番号、ステージ名、ボス名、ステージ種別などの設定情報を
Content Model の設計
まず、ゲームの「ステージ」仕様を Contentful 上で構造化データとして扱えるようにします。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 を使って、次の三種類のみ入力できるようにしています。
battletownevent

これにより、入力時の表記揺れを防ぎながら、 API 側では fields.stageType=battle のような条件でバトルステージだけを厳密に絞り込めます。
Appearance を Dropdown に設定しておくと、モデル作成時の field 入力が簡単になります。

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

クイズ対象の定義
今回のデモでは、クイズに出したいステージを次のように定義しました。
-
バトルステージであること
stageTypeがbattleである
-
ボスが存在すること
bossNameが設定されている
このルールを Contentful のデータ側でしっかり表現しておくことで、アプリ側のクイズ生成ロジックは「バトルステージでボス名が入っているものだけを取ってくる」というシンプルなものにできます。
REST API によるデータ取得
クイズ生成に必要な情報だけを効率よく取得できるように、クエリパラメータを組み合わせます。
バトルステージだけを取得するクエリ
クイズに必要なのは、バトルステージでボスが存在するエントリだけです。そのため、 REST API 呼び出しでは次のような条件を付けています。
content_type=stageStageContent Model だけを対象にします。
fields.stageType=battle- バトルステージだけを取得します。
fields.bossName[exists]=truebossNameが設定されているエントリだけを対象にします。
select=...stageNumbernamebossNamestageTypeなど、クイズに必要なフィールドだけを選択します。
サンプルコード
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 に実装しました。処理の流れは次のとおりです。
- 先ほどの条件で
Stageエントリ一覧を取得します。 - 配列からランダムに一件を選び、そのステージを今回の問題とします。
- 他のステージから
bossNameを取り出し、誤答用の候補としてランダムに数件選びます。 - 正解と誤答候補をまとめてシャッフルし、選択肢配列と正解インデックスを計算します。
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 のデータをクイズに変換する流れなので、スタイル部分の説明は最小限に留めています。
検証と考察
検証の流れ
- Contentful の管理画面から、
Stageエントリを複数作成します。stageNumbernamestageTypeを入力します。- クイズに出したいバトルステージには
bossNameを設定します。

- Next.js プロジェクトのルートで
npm devを実行し、ブラウザからトップページにアクセスします。 - 「クイズを出題」ボタンを押し、 Contentful 上のデータに応じたクイズが表示されることを確認します。

ステージを追加したい場合は、 Contentful に新しい Stage エントリを作成するだけで、クイズのバリエーションが増えます。ボス名の変更やステージ構成の変更も同様で、コードを書き換えずにクイズ内容を更新できます。
Contentful を使って良かった点
今回のデモを通して、 Contentful を使って良かったと感じた点をあらためて整理します。
- ゲームの設定資料を Contentful に集約しておくことで、クイズアプリという別の形で即座に再利用できました。
stageTypeやbossNameのようなフィールド設計をしておくことで、クイズ対象の定義をデータ側に寄せられ、アプリ側のロジックを薄く保てました。- REST API ではクエリパラメータで条件指定とフィールド選択ができるため、クイズ生成に必要な情報だけを効率よく取得できました。
ゲーム開発の現場では、仕様検討やバランス調整のためにステージ設定の一覧が必要になることが多いと思います。そうした情報を Contentful 上で一元管理しておけば、今回のようなクイズデモだけでなく、内部向けの確認ツールやデバッグ用ビューなどにも簡単に展開できます。
まとめ
本記事では Contentful に保存したゲームの「ステージ」仕様から、 Next.js を使って四択クイズを自動生成するデモアプリを紹介しました。 Stage にステージ番号やボス名、ステージ種別を定義し、 REST API のクエリパラメータでバトルステージだけを抽出することで、クイズ生成ロジックをシンプルに保つことができました。
ゲームの仕様書を Contentful のようなヘッドレス CMS に載せておくと、今回のようなクイズアプリを含む周辺ツールを後から増やしやすくなります。






