Next.js と Three.js でブラウザ FPS ゲームを作り、Vercel にデプロイした

Next.js と Three.js でブラウザ FPS ゲームを作り、Vercel にデプロイした

Next.js と Three.js でブラウザ完結型の FPS ゲームを構築し、Vercel にデプロイしました。React と独立したゲームエンジン層を Zustand で橋渡しする 3 層アーキテクチャの設計と、Vercel 上での実際のプレイ体験から得た知見を紹介します。
2026.02.17

はじめに

「ブラウザ上でどこまで FPS (First Person Shooter) 体験が成立するか?」

この疑問を検証するために、Next.js と Three.js を使ったブラウザ FPS ゲームを作り、Vercel にデプロイしました。

地下施設風のステージで 10 体のゾンビを撃破します。

vercel-fps-demo-gif-1

撃破後、開いた扉の先に進むとクリアとなります。

vercel-fps-demo-gif-2

本記事では、このゲームの設計の工夫点である「ゲームエンジン層と React の分離」と、「Vercel デプロイで得た知見」について紹介します。

Vercel とは

Vercel は、Next.js の開発元が提供するフロントエンド向けのクラウドプラットフォームです。Git リポジトリと連携し、push するだけでビルドからグローバル CDN への配信までを自動で行います。

対象読者

  • Three.js やブラウザでの 3D グラフィックスに興味のあるフロントエンドエンジニア
  • React ベースのアプリケーションにリアルタイム描画を組み込みたい方
  • Next.js + Vercel の構成でゲームのような非典型的なアプリケーションをデプロイしたい方

参考

技術スタック

カテゴリ 技術
フレームワーク Next.js 16 (App Router)
言語 TypeScript (strict)
3D レンダリング Three.js
状態管理 Zustand
CSS Tailwind CSS 4
デプロイ Vercel

Three.js のラッパーライブラリとして @react-three/fiber (R3F) も選択肢でしたが、今回は Three.js を直接使用しました。理由は次のセクションで説明するアーキテクチャと関係しています。

ゲームエンジンと React の分離

FPS ゲームでは、入力の取得・ゲームロジックの更新・描画を 1 フレームごとに繰り返す「ゲームループ」が必要です。一方で React のレンダリングサイクルは状態変更に応じて非同期に実行されます。この二つを混在させると、フレーム落ちや状態の不整合が起きやすくなります。

この問題に対処するため、ゲームエンジンを React から完全に分離し、Zustand ストアを唯一の橋渡しとする 3 層アーキテクチャを採用しました。

エンジン層: React に依存しない純粋な TypeScript

ゲームエンジン層は src/game/ ディレクトリにまとめた純粋な TypeScript モジュール群です。React の API は使用しません。

Engine クラスがゲームループを管理し、各サブシステムを毎フレーム更新します。

src/game/engine.ts
private update(timestamp: number): void {
  const rawDt = (timestamp - this.lastTime) / 1000;
  const dt = Math.min(rawDt, GAME.DT_CAP);
  this.lastTime = timestamp;

  const input = this.inputManager.poll();
  const store = useGameStore.getState();

  if (store.game.state === 'PLAYING' && !store.game.paused) {
    this.player.update(dt, input);
    // ... 射撃、敵更新、物理判定、ゲーム進行チェック
  }

  this.syncStore(dt);
  this.renderer.render(this.scene, this.camera);
  this.animationFrameId = requestAnimationFrame((t) => this.update(t));
}

ストアとのやり取りは getState() による同期読み出しと setState() による書き込みだけです。React の useEffectuseState とは無縁であり、ゲームループが React のレンダリングサイクルに影響しない構成になっています。

ストア層: Zustand による単一の共有状態

ストア層は Zustand の単一ストアです。ゲーム状態、プレイヤー情報、入力情報、エフェクト状態などを管理します。

src/store/gameStore.ts
export const useGameStore = create<GameStoreState>((set) => ({
  game: {
    state: 'READY',
    kills: 0,
    hp: PLAYER.MAX_HP,
    timeRemaining: GAME.TIME_LIMIT,
    doorState: 'LOCKED',
    paused: false,
    // ...
  },
  player: { position: { x: 0, y: 0, z: 0 }, velocity: { x: 0, y: 0, z: 0 }, /* ... */ },
  input:  { mode: 'KBM', pointerLocked: false, /* ... */ },
  effects: { damageFlash: 0, hitMarker: null, muzzleFlash: 0 },
  // ...
}));

エンジン層は毎フレーム syncStore() で UI 表示に必要な値 (プレイヤー座標、FPS 情報など) をストアに書き込みます。毎フレーム setState() が走るため、React 側ではセレクタを使って必要な値だけを購読し、不要な再レンダリングを抑制しています。

座標のような高頻度更新値はデバッグ HUD でのみ参照しており、通常プレイ中はデバッグ HUD が非表示のため再レンダリングの負荷にはなりません。

game option

デバッグ HUD の ON/OFF はオプションから選択可能

debug hud on

実際のデバッグ HUD

UI 層: 表示専用の React コンポーネント

UI 層はストアの値を表示するだけで、ゲームロジックを持ちません。

src/app/page.tsx
export default function GamePage() {
  return (
    <main className="relative w-screen h-screen overflow-hidden bg-black">
      <GameCanvas />      {/* z-0: Three.js キャンバス */}
      <HUD />             {/* z-10: キル数、HP、残り時間 */}
      <DamageOverlay />   {/* z-10: 被弾時の赤ビネット */}
      <HitMarker />       {/* z-10: 命中マーカー */}
      <DebugHUD />        {/* z-20: デバッグ情報 */}
      <ReadyScreen />     {/* z-30: 開始画面 */}
      <ClearScreen />     {/* z-30: クリア画面 */}
      <FailedScreen />    {/* z-30: 失敗画面 */}
      <OptionsMenu />     {/* z-40: オプションメニュー */}
    </main>
  );
}

Three.js キャンバスの上に HUD やメニューを z-index で重ねるシンプルな構造です。GameCanvas コンポーネントは useEffect で Engine を生成・起動し、アンマウント時に破棄します。

src/components/GameCanvas.tsx
export default function GameCanvas() {
  const containerRef = useRef<HTMLDivElement>(null);
  const engineRef = useRef<Engine | null>(null);

  useEffect(() => {
    if (!containerRef.current || engineRef.current) return;
    const engine = new Engine();
    engine.init(containerRef.current);
    engine.start();
    engineRef.current = engine;

    return () => {
      engine.stop();
      engine.dispose();
      engineRef.current = null;
    };
  }, []);

  return <div ref={containerRef} className="absolute inset-0 z-0" />;
}

React が担うのは Engine のライフサイクル管理のみであり、Engine 内部のゲームループには関与しません。

Vercel デプロイで得た知見

Next.js + Three.js の組み合わせにおける注意点

Three.js は windowdocument に依存するため、Next.js の SSR (Server-Side Rendering) 環境ではそのまま動作しません。今回は GameCanvas コンポーネントを 'use client' ディレクティブで Client Component として宣言し、useEffect 内でのみ Engine を初期化することで対処しました。

'use client';

import { useEffect, useRef } from 'react';
import { Engine } from '@/game/engine';

export default function GameCanvas() {
  useEffect(() => {
    // Engine の初期化はクライアント側でのみ実行される
    const engine = new Engine();
    engine.init(containerRef.current);
    engine.start();
    // ...
  }, []);
}

Three.js 本体の import は Engine モジュール内で行っているため、今回は next/dynamic による動的インポートを使わずとも動作しました。Client Component の useEffect から呼ばれる時点で、ブラウザ環境が保証されているためです。

ビルド結果と配信

Next.js のビルドでは全ページが静的ページ (Static) として出力されました。

Route (app)
┌ ○ /
└ ○ /_not-found

○  (Static)  prerendered as static content

ゲームのロジックはすべてクライアント側の JavaScript で動作するため、サーバーサイドの処理は不要です。Vercel はビルド成果物を CDN でグローバルに配信するため、どの地域からアクセスしても初回ロードが高速になるという恩恵があります。

今回のビルドログでは約 24 秒でビルドが完了しました。Three.js を含む依存パッケージのインストールに約 12 秒、Next.js のビルド自体は約 11 秒です。ビルドマシンのスペックはプランや設定で変わるため、あくまで参考値です。

実際のプレイ体験

Vercel にデプロイした状態で実際にプレイしたところ、処理落ちやカクつきは感じられず、ローカル開発時と同等の体感でプレイできました。敵 10 体が同時に画面内で動いている場面でもフレームレートは安定しており、射撃やヒット判定のレスポンスにも遅延はありませんでした。

これはゲームロジックがすべてクライアント側で完結しており、サーバーとの通信が発生しない構成のためです。Vercel の役割は静的ファイルの配信に限定されるため、ランタイムの処理性能はユーザーのブラウザとデバイスにのみ依存します。裏を返すと、デバイスのスペックがそのままプレイ体験に直結するということでもあるため、モバイル端末や低スペックマシンでの動作検証は別途必要です。

Pointer Lock API の制約

FPS ゲームでは Pointer Lock API でマウスカーソルを非表示にし、相対的なマウス移動量を取得します。ここでブラウザ固有の制約があります。ユーザーが Esc キーで Pointer Lock を解除した直後は、ブラウザによっては requestPointerLock() の呼び出しがブロックされることがあるというものです。

この制約により、Esc でポーズ → ポーズ解除の流れで Pointer Lock の再取得に失敗するケースが発生しました。対処として、Esc キーハンドラ内での requestPointerLock() 呼び出しをやめ、キャンバスのクリックイベントから再取得する方式に変更しました。

src/game/input.ts
// キャンバスクリックで Pointer Lock を再取得
this.onCanvasClick = () => {
  const game = useGameStore.getState().game;
  if (game.state === 'PLAYING' && !game.paused && !document.pointerLockElement) {
    this.requestPointerLock();
  }
};
canvas.addEventListener('click', this.onCanvasClick);

Pointer Lock が外れている間は HUD に「CLICK TO RESUME」と表示し、プレイヤーにクリック操作を促すようにしています。

click to resume

まとめ

Next.js + Three.js + Zustand の構成で、ブラウザ上でも十分に遊べる FPS 体験を構築できました。

設計面では、ゲームエンジンを React から完全に分離し、Zustand を唯一のインターフェースとする 3 層アーキテクチャが効果的でした。ゲームループと React レンダリングサイクルの競合を避けつつ、HUD やメニューの実装には React の利便性をそのまま活用できます。

Vercel へのデプロイでは、ゲームがすべて静的ページとして配信される恩恵で初回ロードが高速になる一方、Pointer Lock API のようなブラウザ固有の制約への対処が必要でした。

ブラウザゲーム開発に Three.js を直接使うか、R3F を使うかは、プロジェクトの性質によって判断が分かれるところです。リアルタイム性の高い FPS のようなジャンルでは、ゲームループを自前で制御できる直接利用が適していると感じました。

この記事をシェアする

FacebookHatena blogX

関連記事