Next.js × Supabase Realtime でスマホ対戦トグルパズルを作ってみた

Next.js × Supabase Realtime でスマホ対戦トグルパズルを作ってみた

Supabase Realtime の Broadcast と Presence を使い、Next.js でスマホ対戦トグルパズルを実装して Vercel にデプロイしました。結論として、イベント単位で状態が変わるタイプのゲームであれば、Supabase Realtime で遅延を感じない対戦体験が作れます。
2026.04.16

はじめに

Supabase Realtime は、WebSocket を使ってクライアント間でリアルタイムにデータをやり取りできる機能です。チャットアプリでの利用例は多いですが、ゲームのように厳密な同期が求められる場面ではどうでしょうか。

本記事では、Supabase Realtime の Broadcast と Presence を使い、スマホで遊べるリアルタイム対戦トグルパズルを作ります。Vercel にデプロイしてスマートフォン実機で対戦検証した結果、イベント単位で状態が変わるタイプのゲームであれば、体感できる遅延がほぼない対戦体験が作れることがわかりました。

作ったもの

8×8 のグリッド上で 2 人が同時にマスをタップし合う対戦ゲームです。ターン制ではなくリアルタイムでマスを取り合います。 マスをタップすると、そのマスと上下左右のマスの色が反転します。20 秒の制限時間終了時に、自分の色のマスが多い方が勝ちです。

supabase-toggle-game-01

単純な陣取りゲームのように、自分のタップが必ずしも有利に働くとは限りません。反転ルールがあるため、時には利敵行為になってしまうこともあります。相手がどこを押したかがリアルタイムに見えるので、押し返すかどうかの駆け引きが生まれます。

技術スタックは Next.js、Supabase Realtime、Vercel です。Next.js を選んだのは Vercel との親和性が高く、デプロイが容易なためです。

対象読者

  • Supabase Realtime に興味があるが、まだ触ったことがない方
  • チャット以外のリアルタイム通信のユースケースを探している方
  • Next.js と Vercel を使った開発に馴染みがある方

検証環境

  • Next.js 16.2.3
  • @supabase/supabase-js 2.103.1
  • Vercel — アプリのホスティング基盤 (Functions default region: US East iad1)
  • Supabase (プロジェクトリージョン: ap-northeast-2 ソウル) — Realtime の WebSocket 接続先
  • クライアント: 日本国内のスマホ 2 台

参考記事

Supabase プロジェクトのセットアップ

プロジェクトの作成と API Key の取得

Supabase のダッシュボードから新規プロジェクトを作成します。

Create project

作成後、以下の 2 つの値を控えます。

  • Project URL: https://xxxxx.supabase.co 形式の URL
  • anon key: クライアントから使用する公開鍵。ブラウザに露出する前提の鍵だが、それだけで安全になるわけではなく、RLS (Row Level Security) などの権限制御と組み合わせて使用する

Project URL

anon key

Realtime の設定

今回は public channel を前提としたため、Broadcast と Presence を使うだけなら追加のテーブル作成は不要です。Broadcast がデータベースを経由せず WebSocket で直接クライアント間をつなぐ仕組みだからです。

ただし本番用途で参加者を制限したい場合は、private channel と Realtime Authorization の設定が必要です。

Supabase Realtime の 2 つの機能

今回使った機能は Broadcast と Presence の 2 つです。

  • Broadcast
    チャネルに参加しているクライアント全員にメッセージを送る機能です。データベースを介さないため低遅延です。今回はプレイヤーのタップイベントや盤面データの送受信に使います。

  • Presence
    チャネルに誰が参加しているかをリアルタイムに追跡する機能です。ユーザーのオンライン/オフライン状態を管理できます。今回はマッチメイキングに使います。2 人がルームに揃ったことを検知して、ゲームを開始します。

同期の設計

リアルタイム対戦で最も難しいのは、2 台の端末で盤面の状態を一致させることです。方式の選択がゲーム体験を左右します。

操作イベントのみを送る方式を選んだ理由

盤面の同期方式には大きく 2 つの選択肢があります。ひとつは盤面全体を毎回送る方式です。もうひとつは「どのマスがタップされたか」というイベントだけを送る方式です。

今回は操作イベントのみを送る方式を選びました。盤面全体 (64 マス分のデータ) を毎フレーム送るのは通信量が大きく、遅延の原因になります。一方、タップイベントは座標ひとつ分のデータで済みます。

// タップイベントを Broadcast で送信
channelRef.current?.send({
  type: "broadcast",
  event: "game",
  payload: { type: "tap", index },
});
// 受信したタップイベントをローカル盤面に適用 (常に最新の盤面から計算する)
channel.on("broadcast", { event: "game" }, ({ payload }) => {
  if (payload.type === "tap") {
    gameState.applyRemoteTap(payload.index);
  }
});

受信側では React の state 経由ではなく、ref で保持した最新の盤面から計算します。短時間に複数のタップを受信しても、古い盤面を基に計算してしまう問題を防ぐためです。

ただし、この方式には弱点があります。両者がほぼ同時にタップした場合、イベントの到達順がクライアントごとに異なり、盤面にずれが生じる可能性があります。

ホスト/ゲスト方式で整合性を担保する

盤面のずれを放置するとゲームとして成立しません。そこでホスト/ゲスト方式を導入しました。

ルームを作成したプレイヤーをホストとします。ホストは 5 秒おきに自分の盤面全体を Broadcast し、ゲスト側の盤面を上書きします。通常のタップ同期はイベント単位の軽量な通信で行い、定期的に盤面全体で補正するというハイブリッドな設計です。

// ホスト: 5 秒おきに盤面全体を同期
syncIntervalRef.current = setInterval(() => {
  channelRef.current?.send({
    type: "broadcast",
    event: "game",
    payload: { type: "sync", board: gameStateRef.current.board },
  });
}, 5000);

5 秒という間隔は、通信量を抑えつつ体感上のずれを許容範囲に収めるバランスで設定しました。加えて、ゲーム終了時にはホストが最終盤面と勝敗結果を Broadcast し、ゲスト側はその結果を表示します。これにより、終了直前に盤面がずれていても勝敗の食い違いを防げます。

マッチメイキングに Presence を使う

マッチメイキングには Presence を使いました。専用のマッチメイキングサーバーを立てる必要がなく、Supabase のチャネル機能だけで完結します。

ルーム ID を含む URL を相手に共有し、2 人がチャネルに参加したらゲームを開始します。

const channel = supabase.channel(`room:${roomId}`, {
  config: { presence: { key: crypto.randomUUID() } },
});

channel.on("presence", { event: "sync" }, () => {
  const count = Object.keys(channel.presenceState()).length;
  if (count >= 2) {
    // 2 人揃った
  }
});

await channel.subscribe();
await channel.track({});

presenceState() でチャネル内の参加者数を取得できます。track({}) を呼ぶと自分の参加が他のクライアントに通知されます。ランダムマッチングは今回のスコープ外とし、URL 共有方式に限定しました。

Vercel へのデプロイ

デプロイ手順

Vercel CLI を使ってデプロイします。

npm i -g vercel
vercel          # プロジェクトのリンクと初回デプロイ
vercel --prod   # 本番環境へのデプロイ

環境変数の設定

Supabase の接続情報を Vercel の環境変数に設定します。NEXT_PUBLIC_ プレフィックスが付いた環境変数はブラウザ向けバンドルに埋め込まれます。Supabase の anon key はクライアント公開前提の鍵ですが、前述の通り RLS と組み合わせて使用することが前提です。

vercel env add NEXT_PUBLIC_SUPABASE_URL production
vercel env add NEXT_PUBLIC_SUPABASE_KEY production

環境変数を追加した後は再デプロイが必要です。

vercel --prod

デプロイ状況の確認

デプロイの状態は vercel ls で確認できます。

vercel ls

本番環境で問題が発生した場合は vercel logs でランタイムログを確認します。

vercel logs --deployment <deployment-url>

遊んでみた所感

Vercel にデプロイしたアプリをスマホで開き、同僚と実際に対戦してみました。結論として、今回の検証では体感できるラグはほぼありませんでした。

ゲームプレイ中の通信はブラウザから Supabase Realtime の WebSocket サーバーに直接行われ、Vercel Functions は経由しません。つまり遅延に影響するのはブラウザと Supabase 間の経路です。検証環境に記載の通り Supabase のリージョンがソウルだったため、日本からの接続には比較的有利な条件でした。その条件を踏まえても、相手の操作がリアルタイムに反映されているように見えました。

面白いのは、厳密にリアルタイム同期ができていなくても体験上は問題ないという点です。相手がどのマスをいつ押したかという正確な情報は、自分の画面を見ているだけでは検証のしようがありません。受信したタップイベントは即座にローカル盤面に反映されるため、体感としてはラグなく同期しているように見えます。ゲームデザインがうまく遅延を隠してくれているとも言えます。

supabase-toggle-game-02

実際には盤面に同期ズレがあるが、スマホでのプレイでは互いの盤面が見えないため気にならない。

同僚からは「相手がどこを押したかリアルタイムに見えるので、押し返す戦略を考えたりと駆け引きがあって面白い」という感想をもらいました。

フレーム単位の同期が求められる格闘ゲームやシューティングでは Supabase Realtime は厳しいでしょう。しかし、トグルパズルのように「イベント単位で状態が変わる」タイプのゲームであれば、Broadcast で十分に遊べる対戦体験が作れます。

本番運用で考慮すべき点

今回のデモでは割り切った部分がいくつかあります。本番用途で参考にする場合は以下の点に注意してください。

ブラウザが突然閉じられた場合、unsubscribe が呼ばれず Supabase 側に接続が残留することがあります。実際に今回の検証でも、テストを繰り返すうちに接続が残留し、新規接続が TIMED_OUT になる現象が発生しました。 Supabase プロジェクトの restart で解消しましたが、本番では beforeunload イベントで明示的にチャネルを切断する処理を入れるべきです。

また、前述の通り今回は public channel を使用しているため、ルーム ID を知っていれば誰でも参加できます。不正な参加や妨害を防ぐには、private channel と Realtime Authorization の導入が必要です。

まとめ

Supabase Realtime の Broadcast と Presence を使って、スマホ向けリアルタイム対戦トグルパズルを作り、Vercel にデプロイして実機で対戦しました。Broadcast はデータベースを経由しない低遅延な通信手段で、操作イベント同期とホスト/ゲスト方式を組み合わせることで、軽量な通信と盤面の整合性を両立できました。チャットアプリ以外でも、今回のようなカジュアル対戦ゲームでは Supabase Realtime は十分に活用できます。

付録: サンプルコード

lib/supabase.ts

import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY!;

export const supabase = createClient(supabaseUrl, supabaseKey);

lib/game-logic.ts

export const GRID_SIZE = 8;
export const TOTAL_CELLS = GRID_SIZE * GRID_SIZE;
export const GAME_DURATION_SEC = 20;
export const COOLDOWN_MS = 200;
export const SYNC_INTERVAL_MS = 5000;

export type PlayerColor = "A" | "B";
export type Board = PlayerColor[];

export function createEmptyBoard(): Board {
  return new Array(TOTAL_CELLS).fill("A");
}

export function createRandomBoard(): Board {
  const board: Board = new Array(TOTAL_CELLS);
  const indices = Array.from({ length: TOTAL_CELLS }, (_, i) => i);

  for (let i = indices.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [indices[i], indices[j]] = [indices[j], indices[i]];
  }

  for (let i = 0; i < TOTAL_CELLS; i++) {
    board[indices[i]] = i < TOTAL_CELLS / 2 ? "A" : "B";
  }

  return board;
}

function toIndex(row: number, col: number): number {
  return row * GRID_SIZE + col;
}

export function toRowCol(index: number): [number, number] {
  return [Math.floor(index / GRID_SIZE), index % GRID_SIZE];
}

function getToggleTargets(index: number): number[] {
  const [row, col] = toRowCol(index);
  const targets = [index];

  if (row > 0) targets.push(toIndex(row - 1, col));
  if (row < GRID_SIZE - 1) targets.push(toIndex(row + 1, col));
  if (col > 0) targets.push(toIndex(row, col - 1));
  if (col < GRID_SIZE - 1) targets.push(toIndex(row, col + 1));

  return targets;
}

function flipColor(color: PlayerColor): PlayerColor {
  return color === "A" ? "B" : "A";
}

export function applyToggle(board: Board, index: number): Board {
  const newBoard = [...board];
  const targets = getToggleTargets(index);

  for (const t of targets) {
    newBoard[t] = flipColor(newBoard[t]);
  }

  return newBoard;
}

export function countColors(board: Board): { A: number; B: number } {
  let a = 0;
  let b = 0;

  for (const cell of board) {
    if (cell === "A") a++;
    else b++;
  }

  return { A: a, B: b };
}

export type GameResult = "A" | "B" | "draw";

export function judgeWinner(board: Board): GameResult {
  const { A, B } = countColors(board);
  if (A > B) return "A";
  if (B > A) return "B";
  return "draw";
}

hooks/useGameState.ts

"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import {
  type Board,
  type PlayerColor,
  type GameResult,
  createEmptyBoard,
  applyToggle,
  countColors,
  judgeWinner,
  GAME_DURATION_SEC,
  COOLDOWN_MS,
} from "@/lib/game-logic";

export type GamePhase = "waiting" | "playing" | "finished";

type OnTapCallback = (index: number) => void;

export function useGameState(onTap?: OnTapCallback) {
  const [board, setBoardState] = useState<Board>(() => createEmptyBoard());
  const [phase, setPhase] = useState<GamePhase>("waiting");
  const [myColor, setMyColor] = useState<PlayerColor>("A");
  const [timeLeft, setTimeLeft] = useState(GAME_DURATION_SEC);
  const [result, setResult] = useState<GameResult | null>(null);

  const lastTapTime = useRef(0);
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const endTimeRef = useRef(0);
  const boardRef = useRef(board);
  boardRef.current = board;

  const { A: countA, B: countB } = countColors(board);

  const stopTimer = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  const finishGame = useCallback(() => {
    stopTimer();
    setPhase("finished");
    setResult(judgeWinner(boardRef.current));
  }, [stopTimer]);

  const startGame = useCallback(
    (initialBoard: Board, color: PlayerColor) => {
      setBoardState(initialBoard);
      boardRef.current = initialBoard;
      setMyColor(color);
      setPhase("playing");
      setResult(null);
      setTimeLeft(GAME_DURATION_SEC);
      endTimeRef.current = Date.now() + GAME_DURATION_SEC * 1000;

      stopTimer();
      timerRef.current = setInterval(() => {
        const remaining = Math.max(
          0,
          (endTimeRef.current - Date.now()) / 1000
        );
        setTimeLeft(remaining);
        if (remaining <= 0) finishGame();
      }, 100);
    },
    [stopTimer, finishGame]
  );

  const handleCellTap = useCallback(
    (index: number) => {
      if (phase !== "playing") return;
      const now = Date.now();
      if (now - lastTapTime.current < COOLDOWN_MS) return;
      lastTapTime.current = now;

      const newBoard = applyToggle(boardRef.current, index);
      setBoardState(newBoard);
      boardRef.current = newBoard;
      onTap?.(index);
    },
    [phase, onTap]
  );

  const setBoard = useCallback((newBoard: Board) => {
    setBoardState(newBoard);
    boardRef.current = newBoard;
  }, []);

  const resetGame = useCallback(() => {
    stopTimer();
    setPhase("waiting");
    setResult(null);
    setTimeLeft(GAME_DURATION_SEC);
    setBoardState(createEmptyBoard());
  }, [stopTimer]);

  useEffect(() => {
    return () => stopTimer();
  }, [stopTimer]);

  const applyRemoteTap = useCallback((index: number) => {
    const newBoard = applyToggle(boardRef.current, index);
    setBoardState(newBoard);
    boardRef.current = newBoard;
  }, []);

  const finishWithResult = useCallback(
    (remoteResult: GameResult) => {
      stopTimer();
      setPhase("finished");
      setResult(remoteResult);
    },
    [stopTimer]
  );

  return {
    board, phase, myColor, timeLeft,
    countA, countB, result,
    handleCellTap, applyRemoteTap, startGame,
    resetGame, setBoard, finishWithResult, finishGame,
  };
}

hooks/useOnlineGame.ts

"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { supabase } from "@/lib/supabase";
import {
  type Board,
  type PlayerColor,
  type GameResult,
  judgeWinner,
  SYNC_INTERVAL_MS,
} from "@/lib/game-logic";
import type { useGameState } from "@/hooks/useGameState";

type GameStateHandle = ReturnType<typeof useGameState>;

type BroadcastPayload =
  | { type: "tap"; index: number }
  | { type: "start"; board: Board; myColorForGuest: PlayerColor }
  | { type: "sync"; board: Board }
  | { type: "result"; winner: GameResult; board: Board };

export function useOnlineGame(roomId: string, gameState: GameStateHandle) {
  const [isConnected, setIsConnected] = useState(false);
  const [opponentJoined, setOpponentJoined] = useState(false);
  const [isHost, setIsHost] = useState(false);

  const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
  const syncIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const gameStateRef = useRef(gameState);
  const isHostRef = useRef(false);
  gameStateRef.current = gameState;

  const broadcastTap = useCallback((index: number) => {
    channelRef.current?.send({
      type: "broadcast",
      event: "game",
      payload: { type: "tap", index } satisfies BroadcastPayload,
    });
  }, []);

  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`, {
      config: { presence: { key: crypto.randomUUID() } },
    });
    channelRef.current = channel;

    channel
      .on("presence", { event: "sync" }, () => {
        const state = channel.presenceState();
        const count = Object.keys(state).length;
        setOpponentJoined(count >= 2);
        if (count === 1) {
          isHostRef.current = true;
          setIsHost(true);
        }
      })
      .on("broadcast", { event: "game" }, ({ payload }) => {
        const msg = payload as BroadcastPayload;
        const gs = gameStateRef.current;
        switch (msg.type) {
          case "tap":
            gs.applyRemoteTap(msg.index);
            break;
          case "start":
            gs.startGame(msg.board, msg.myColorForGuest);
            break;
          case "sync":
            if (!isHostRef.current) gs.setBoard(msg.board);
            break;
          case "result":
            if (!isHostRef.current) {
              gs.setBoard(msg.board);
              gs.finishWithResult(msg.winner);
            }
            break;
        }
      })
      .subscribe(async (status) => {
        if (status === "SUBSCRIBED") {
          await channel.track({});
          setIsConnected(true);
        }
      });

    return () => {
      if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
      supabase.removeChannel(channel);
    };
  }, [roomId]);

  useEffect(() => {
    if (!isHost || gameState.phase !== "playing") {
      if (syncIntervalRef.current) {
        clearInterval(syncIntervalRef.current);
        syncIntervalRef.current = null;
      }
      return;
    }
    syncIntervalRef.current = setInterval(() => {
      channelRef.current?.send({
        type: "broadcast",
        event: "game",
        payload: {
          type: "sync",
          board: gameStateRef.current.board,
        } satisfies BroadcastPayload,
      });
    }, SYNC_INTERVAL_MS);

    return () => {
      if (syncIntervalRef.current) {
        clearInterval(syncIntervalRef.current);
        syncIntervalRef.current = null;
      }
    };
  }, [isHost, gameState.phase]);

  // Host: broadcast final result when game finishes
  useEffect(() => {
    if (!isHost || gameState.phase !== "finished") return;

    const board = gameStateRef.current.board;
    channelRef.current?.send({
      type: "broadcast",
      event: "game",
      payload: {
        type: "result",
        winner: judgeWinner(board),
        board,
      } satisfies BroadcastPayload,
    });
  }, [isHost, gameState.phase]);

  const broadcastStart = useCallback(
    (board: Board, myColor: PlayerColor) => {
      channelRef.current?.send({
        type: "broadcast",
        event: "game",
        payload: {
          type: "start",
          board,
          myColorForGuest: myColor === "A" ? "B" : "A",
        } satisfies BroadcastPayload,
      });
    },
    []
  );

  return { isConnected, opponentJoined, isHost, broadcastTap, broadcastStart };
}

この記事をシェアする

関連記事