なぜNext.jsでブラウザゲームを作ったのか
筆者らaduceでは、ブラウザで遊べる本格4人打ち麻雀ゲーム 雀天(janten.net) を開発・運営しています。ゲーム開発というとUnityやUnreal Engineを想像するかもしれませんが、雀天ではあえてNext.jsを採用しました。
理由はシンプルです。麻雀はターン制のゲームであり、60fpsのリアルタイム描画は不要です。一方で、SEO対応のブログ記事や手牌画像を返すAPIなど、Webアプリケーションとしての機能が多く求められます。Next.jsであれば、ゲームUI・コンテンツページ・APIエンドポイントを一つのプロジェクトで統合的に管理できます。
雀天の技術スタック一覧
| カテゴリ | 技術 | バージョン |
|---|---|---|
| フレームワーク | Next.js | 16.2 |
| UI | React | 19.2 |
| 言語 | TypeScript | 5.9 |
| ゲームロジック | mahjong-core(自作パッケージ) | 1.0 |
| ビルド(コア) | tsup | 8.x |
| テスト | Vitest | 4.1 |
| コンテンツ | MDX + remark-gfm + rehype-slug | - |
フロントエンドはReact 19のClient Componentsで構築し、ゲームロジックはローカルnpmパッケージとして完全に分離しています。
ゲームロジックをnpmパッケージとして分離する設計
雀天の設計で最も重要な判断は、麻雀のルール処理を mahjong-core という独立パッケージに切り出したことです。
// package.json
{
"dependencies": {
"mahjong-core": "file:./mahjong-core"
}
}mahjong-core は tsup でビルドし、CJS/ESM両対応の型付きパッケージとして出力します。これにより、Next.jsのフロントエンドからもAPI Routeからも同じロジックを利用できます。UIの都合でゲームルールが汚染されることがなく、テストも純粋なロジックテストとして高速に実行できます。
純関数ベースのゲーム状態管理
ゲームの状態遷移は純関数で設計しています。例えばポン(他家の捨て牌を取る動作)の処理は次のようになります。
export function executePon(
player: number,
tile: Tile,
discarder: number,
hands: Tile[][],
melds: Meld[][],
discards: Tile[][],
): PonResult {
const nH = hands.map(h => [...h]);
const nM = melds.map(m => [...m]);
const nD = discards.map(d => [...d]);
nD[discarder].pop();
// ... 手牌から同じ牌を2枚取り出して副露に追加
nM[player].push({ type: "pon", tiles: ponT, closed: false });
return { hands: nH, melds: nM, discards: nD, ippatsu: nIp };
}入力の配列をスプレッド構文でコピーし、元の状態を変更せずに新しい状態を返します。この設計には大きなメリットがあります。副作用がないため、どんな状態からでもテストが書けること。そしてUI側のカスタムフック useMahjongGame は、これらの純関数を呼び出して useState に結果をセットするだけで済むことです。
モバイル対応:TileSizeContextによるレスポンシブ設計
麻雀ゲームのUIはモバイル対応が難しいジャンルです。136枚の牌、4人の手牌と捨て牌を限られた画面に収める必要があります。雀天では TileSizeContext というReact Contextで画面サイズに応じた牌の寸法を一元管理しています。
const mob = vw < 500;
const nar = vw < 680;
const landscape = vh <= 500 && vw > vh;
const lsScale = landscape
? Math.min(1.2, Math.max(0.7, vh / 430))
: 1;
const T_H = mob ? 30 : nar ? 38 : 46;
const TS_W = mob ? 16 : nar ? 20 : 24;
const TS_H = mob ? 22 : nar ? 27 : 32;通常のレスポンシブ(モバイル/ナロー/デスクトップ)に加え、スマートフォンの横持ちにも対応しています。lsScale はiPhone 14 Pro Maxの横向き高さ430pxを基準にスケール係数を計算し、CSS変数として各コンポーネントに注入します。メディアクエリだけでは対応しきれないゲーム特有のレイアウト問題を、Context経由の動的な値で解決しています。
SVG手牌画像APIの実装
雀天にはNext.jsのAPI Routeで実装した手牌画像生成APIがあります。URLパラメータで牌の記法を渡すと、SVG画像を返します。
GET /api/mahjong/tehai?tiles=123m456p789s11z&shanten=true&waits=true
export async function GET(request: NextRequest) {
const tilesParam = searchParams.get("tiles");
const tiles = parseTileNotation(tilesParam);
// 向聴数・待ち牌もmahjong-coreで計算
const shantenNum = calcShanten(allHandTiles as Tile[]);
const waits = getWaits(tiles as Tile[]);
const svg = renderSvg({ tiles, tsumo, doraKeys, shantenNum, waits, ... });
return new NextResponse(svg, {
headers: { "Content-Type": "image/svg+xml" },
});
}ゲームロジックを mahjong-core に分離していたことで、API Routeからも向聴数計算や待ち牌判定をそのまま呼び出せています。この画像APIはブログ記事への埋め込みやSNSでのシェアに活用しており、ゲームとコンテンツが同じコードベースにあるNext.jsの強みを活かした機能です。
まとめ
Next.jsでのブラウザゲーム開発は、ターン制ゲームとWebコンテンツの統合という観点で非常に合理的な選択です。雀天 の開発を通じて得た知見をまとめます。
- ゲームロジックは独立パッケージに分離し、フロントエンドとAPIの両方から利用する
- 状態遷移は純関数で設計し、テスタビリティと保守性を確保する
- React Contextで画面サイズに応じた牌寸法を一元管理し、モバイル・横持ちに対応する
- API Routeとゲームロジックの共有で、画像生成などの付加価値機能を低コストで実装する
aduceでは、このような技術選定から設計・実装まで一貫したIT開発支援を行っています。Webアプリケーションやゲーム開発に関するご相談は、お気軽に お問い合わせ ください。