Supabase Realtime入門:BroadcastとPresenceで、ゲームの状態や「今だれがいるか」を共有する

対象: 「画面を更新しなくても、相手の操作が勝手に反映される」仕組みを、自分のアプリに入れてみたい方
所要時間: 30〜40分 / 使うもの: Supabaseの無料プラン、ブラウザ、ターミナル(簡単なサーバーを1つ立てます)
続きの記事(このしくみを、実際のWebアプリにしてみた): 「みんなで同時に見る」ライブ注文ボードをつくってみた:誰が見ていて、誰が操作中かが分かる
ビジネス編(やさしい解説): AIでアプリをつくる人のための「リアルタイム」入門:画面を更新しなくても変わる仕組みと使いどころ

オンラインの対戦ゲームでも、共同で使うホワイトボードでも、「相手が動いたら、こちらの画面がすぐ変わる」ってありますよね。あれをつくるには、サーバーを自前で立てて、WebSocketを…と身構えていました。

でも調べてみると、SupabaseのRealtimeは、1枚のHTMLファイルだけで試せるらしい。そこで、2人で遊べる三目並べをつくって、次の2つを動かしてみました。

  • Broadcast:「ここに置いた」という出来事を、部屋の全員に伝える
  • Presence:「今だれが部屋にいるか」を共有する

つまずいたところ(途中から入った人の盤面が空っぽ)も、そのまま書きます。

2人が入室した三目並べ

0. 先に伝えておきたいこと(正直ベース)

  • 動作確認は、同じパソコンのブラウザで、タブを2〜4つ開いて行いました(Alice、Bob、観戦のCarolとDave)。別々の端末・別々のネットワークからは試していません。
  • ゲームのルール判定(誰の番か、勝ちか)はブラウザ側だけでやっています。悪意のある人が不正な手を送れば通ってしまう作りです(試してはいませんが、仕組み上そうなります)。学習用のデモとして読んでください。
  • 使うのは公開用の鍵(publishable key)です。ブラウザに入れて使う前提の鍵ですが、この記事には載せません。service_role などの秘密の鍵は、絶対にブラウザのコードに入れないでください。

1. 全体の仕組み

Alice のタブ ─┐
Bob   のタブ ─┼── 同じ「部屋(チャンネル game-room-1)」に入る ──→ Supabase Realtime
Carol のタブ ─┘
  ・手を打つ  → Broadcast で「cell=4, mark=X」と全員に送る
  ・入室/退室 → Presence が「今いる人の名簿」を自動で更新する
機能 役割 たとえ
Broadcast その瞬間の出来事を全員に伝える 放送
Presence 今だれがいるかの名簿を共有する 出席簿

ドキュメントには、Presenceは頻繁に変わる情報(マウスの動きなど)には向かない、そういう用途にはBroadcastを使う、と書かれていました。今回の「手を打つ」はBroadcastに、「誰がいるか」はPresenceに分けたのは、その考え方に沿っています。

2. 準備:プロジェクトURLと公開鍵

Connect の画面から、プロジェクトのURLと公開鍵(sb_publishable_...)を確認します(Settings → API Keys にもあります)。

Realtimeは、プロジェクトを作った時点で有効になっていました。確認するには、左メニューの Realtime → Settings を見ます。

Realtimeの設定:サービスと、公開チャンネルの許可

ここに、あとで大事になる設定があります。Allow public access to channels(チャンネルの公開アクセスを許可)が、最初からオンでした。「オフにすると、プライベートチャンネルだけが許可される」と書かれています。今回のデモがログイン無しで動くのは、これのおかげです。(セキュリティの話は、あとで書きます。)

3. 1枚のHTMLをつくる

次のファイルを index.html として保存します。__SUPABASE_URL__ と __SUPABASE_KEY__ は、あなたのプロジェクトのURLと公開鍵に置き換えてください。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>三目並べ(Realtimeデモ)</title>
<style>
  body { font-family: -apple-system, "Hiragino Sans", sans-serif; background:#f6f7f9; margin:0; padding:24px; color:#222; }
  #app { width:360px; background:#fff; border-radius:16px; padding:20px; box-shadow:0 2px 10px rgba(0,0,0,.08); }
  h1 { font-size:20px; margin:0 0 8px; }
  #me { font-size:14px; color:#555; margin-bottom:6px; }
  #who { font-size:14px; margin-bottom:14px; padding:8px 10px; background:#eef6ff; border-radius:8px; }
  #board { display:grid; grid-template-columns:repeat(3,100px); gap:6px; }
  .cell { width:100px; height:100px; font-size:44px; font-weight:700; border:2px solid #ccd; border-radius:10px; background:#fafbff; cursor:pointer; }
  .cell:disabled { cursor:default; }
  .X { color:#2563eb; } .O { color:#dc2626; }
  #msg { margin-top:12px; font-size:15px; min-height:22px; font-weight:600; }
  button.reset { margin-top:10px; padding:6px 12px; border-radius:8px; border:1px solid #ccd; background:#fff; cursor:pointer; }
</style>
</head>
<body>
<div id="app">
  <h1>三目並べ</h1>
  <div id="me"></div>
  <div id="who">接続中…</div>
  <div id="board"></div>
  <div id="msg"></div>
  <button class="reset" id="reset">最初からやり直す</button>
</div>

<script type="module">
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

// ?name=Alice&mark=X  /  ?name=Bob&mark=O  /  ?name=Carol(観戦)  /  &resync=1(途中参加で盤面を聞く)
const params = new URLSearchParams(location.search)
const name = params.get('name') || 'ゲスト'
const mark = params.get('mark') || ''
const resync = params.get('resync') === '1'

const supabase = createClient('__SUPABASE_URL__', '__SUPABASE_KEY__')
const room = supabase.channel('game-room-1', {
  config: { broadcast: { self: false }, presence: { key: name } },
})

let board = Array(9).fill('')
let turn = 'X'

const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]]
const winner = () => { for (const [a,b,c] of LINES) if (board[a] && board[a]===board[b] && board[a]===board[c]) return board[a]; return board.every(Boolean) ? 'draw' : '' }

function render() {
  document.getElementById('me').textContent = mark ? `あなた:${name}(${mark})` : `あなた:${name}(観戦)`
  const el = document.getElementById('board'); el.innerHTML = ''
  board.forEach((v, i) => {
    const b = document.createElement('button')
    b.className = 'cell ' + v; b.textContent = v
    b.disabled = !mark || !!v || !!winner() || turn !== mark
    b.onclick = () => play(i, mark, true)
    el.appendChild(b)
  })
  const w = winner()
  document.getElementById('msg').textContent = w === 'draw' ? '引き分け' : w ? `${w} の勝ち!` : `${turn} の番`
}

function play(i, m, send) {
  if (board[i] || winner()) return
  board[i] = m; turn = m === 'X' ? 'O' : 'X'; render()
  if (send) room.send({ type: 'broadcast', event: 'move', payload: { cell: i, mark: m } })   // 手を、同じ部屋の全員に送る
}

// 手を受け取る(Broadcast)
room.on('broadcast', { event: 'move' }, ({ payload }) => play(payload.cell, payload.mark, false))
room.on('broadcast', { event: 'reset' }, () => { board = Array(9).fill(''); turn = 'X'; render() })

// 途中参加の人が「今の盤面を教えて」と聞き、プレイヤーが答える
room.on('broadcast', { event: 'state-request' }, () => {
  if (mark) room.send({ type: 'broadcast', event: 'state', payload: { board, turn } })
})
room.on('broadcast', { event: 'state' }, ({ payload }) => { board = payload.board; turn = payload.turn; render() })

// 今だれがいるか(Presence)
room.on('presence', { event: 'sync' }, () => {
  const people = Object.values(room.presenceState()).flat()
  const players = people.filter(p => p.mark).map(p => `${p.name}(${p.mark})`)
  const watchers = people.filter(p => !p.mark).map(p => p.name)
  document.getElementById('who').textContent =
    `オンライン ${people.length}人:` + [...players, ...watchers.map(n => `${n}(観戦)`)].join('、')
})

document.getElementById('reset').onclick = () => {
  board = Array(9).fill(''); turn = 'X'; render()
  room.send({ type: 'broadcast', event: 'reset', payload: {} })
}

room.subscribe(async (status) => {
  if (status !== 'SUBSCRIBED') return
  await room.track({ name, mark, online_at: new Date().toISOString() })
  if (resync) room.send({ type: 'broadcast', event: 'state-request', payload: {} })
})

render()
</script>
</body>
</html>

ポイントになる部分だけ、解説します。

部屋に入る(チャンネル)

const room = supabase.channel('game-room-1', {
  config: { broadcast: { self: false }, presence: { key: name } },
})
  • game-room-1 が部屋の名前です。同じ名前の部屋に入った人同士で、メッセージを共有します。
  • broadcast: { self: false }:自分が送った手は、自分には返ってこない(自分の画面は、打った時にすぐ更新しているため)
  • presence: { key: name }:名簿の中での自分の名前(キー)です

手を送る・受け取る(Broadcast)

// 送る
room.send({ type: 'broadcast', event: 'move', payload: { cell: i, mark: m } })

// 受け取る
room.on('broadcast', { event: 'move' }, ({ payload }) => play(payload.cell, payload.mark, false))

今だれがいるか(Presence)

// 名簿が変わるたびに呼ばれる
room.on('presence', { event: 'sync' }, () => {
  const people = Object.values(room.presenceState()).flat()
  ...
})

// 部屋に入れたら、自分を名簿に載せる
room.subscribe(async (status) => {
  if (status !== 'SUBSCRIBED') return
  await room.track({ name, mark, online_at: new Date().toISOString() })
})

track() で自分の情報を名簿に載せます。タブを閉じる(接続が切れる)と、自動で名簿から消えます。

4. 動かしてみる

HTMLを置いたフォルダで、簡単なサーバーを立てます(ファイルを直接開くより、トラブルが少なかったためです)。

python3 -m http.server 8765 --bind 127.0.0.1

ブラウザで、タブを2つ開きます。

  • http://127.0.0.1:8765/index.html?name=Alice&mark=X
  • http://127.0.0.1:8765/index.html?name=Bob&mark=O

どちらのタブにも、「オンライン 2人:Alice(X)、Bob(O)」と出ました。 画面を更新していないのに、相手が入ったことがわかります。これがPresenceです。

交互に打ってみる

Aliceのタブで真ん中、Bobのタブで左上、Aliceで右下、Bobで右上、と打ちました。結果は、どちらのタブも同じ盤面になっていました。

Aliceの画面
Bobの画面(同じ盤面)

自分のタブで打った手は自分の画面に、相手のタブで打った手は、更新ボタンを押さなくてもこちらの画面に出ます。これがBroadcastです。

5. つまずき:途中から入った人の盤面が空っぽ

3つ目のタブで、観戦者を入れてみました。

  • http://127.0.0.1:8765/index.html?name=Carol
Carolの画面:名簿には3人いるのに、盤面は空

名簿は「オンライン 3人:Alice(X)、Bob(O)、Carol(観戦)」と、ちゃんと正しい。でも盤面は空っぽです(しかも「Xの番」と出ています)。

なぜ?

Broadcastは「その瞬間の放送」です。放送を聞いていなかった人には、過去の分は届きません。Carolは、手が打たれたあとから入室したので、1手目〜3手目の放送を聞いていないのです。一方、Presenceの名簿は「今の状態」なので、あとから入っても正しく見えます。

学び:Broadcastは過去を覚えていない。途中参加の人のために、「今の状態」を渡す工夫が要る。

解決:入ったときに「今の盤面、教えて」と聞く

コードに、次の3つの仕組みを入れてありました(&resync=1 を付けたときに有効になります)。

// 入室したら「状態を教えて」と放送する
if (resync) room.send({ type: 'broadcast', event: 'state-request', payload: {} })

// プレイヤーは、聞かれたら「今の盤面」を放送で返す
room.on('broadcast', { event: 'state-request' }, () => {
  if (mark) room.send({ type: 'broadcast', event: 'state', payload: { board, turn } })
})

// 返事を受け取ったら、自分の盤面に反映する
room.on('broadcast', { event: 'state' }, ({ payload }) => { board = payload.board; turn = payload.turn; render() })

4つ目のタブで試します。

  • http://127.0.0.1:8765/index.html?name=Dave&resync=1
Daveの画面:途中から入っても盤面が追いついた

途中から入ったDaveにも、盤面が出ました。 プレイヤー(AliceとBob)が答えてくれたからです。

今回の方法は、誰かプレイヤーが残っていないと答えてもらえません(全員いなくなったら、盤面は消えます)。ずっと残したいなら、盤面をデータベースに保存する、という別の方法が必要だと思います(私は今回試していません)。

6. 退室も、ほぼすぐ反映される

CarolとDaveのタブを閉じて、Aliceの画面の名簿を見ていました。

タブを閉じてから Aliceの名簿
1秒後 Alice(X)、Bob(O)、Dave(観戦)(Carolが消えた)
3秒後 Alice(X)、Bob(O)(Daveも消えた)
退室後の名簿

1〜3秒で反映されました。これは1回きりの観測で、ネットワークの状態によって変わると思います。

7. ダッシュボードで、流れているメッセージを見る

Realtime → Inspector で、部屋のメッセージを覗けます。Join a channel からチャンネル名(game-room-1)を入れて、Listen to channel を押します。

チャンネルに参加する画面

見落としやすい設定:Is channel private?(このチャンネルはプライベート?)というスイッチがあります。「プライベートにすると、RLSのポリシーでメッセージを絞り込む」と書かれていました。今回はオフ(公開)のまま使っています。

新しいゲームを始めると、Presenceの入室(join/sync)と、Broadcastの手(move)がずらっと並びます。1つ選ぶと、中身が見えます。

Inspectorに流れるメッセージ。moveの中身は cell と mark
{ "type": "broadcast", "event": "move", "payload": { "cell": 0, "mark": "X" } }

デバッグのとき、「送ったはずなのに届かない」を確かめるのに便利そうです。

8. 気をつけたいこと

① 公開チャンネルは、鍵を知っていれば誰でも入れる

さきほどの Allow public access to channels がオンの状態では、公開鍵(ブラウザに入れる鍵)を知っている人なら、誰でも game-room-1 に入って、メッセージを送れます(公開鍵は、ブラウザのコードを見れば誰でも分かります)。ゲームなら「偽の手を送られる」可能性があります。

対策として、プライベートチャンネル(ログインした人だけ、RLSのポリシーで許可した人だけ)に切り替える方法が用意されています。これは私は今回試していません。本番で使うなら、必ず検討したい部分です。

② 無料プランの上限

Realtime → Settings に、上限が表示されていました。

同時接続クライアント数と、1秒あたりのイベント数の上限
  • 同時接続:200クライアント
  • 1秒あたりのイベント数:100 events/s(無料プランでは、画面上は変更できない状態でした)

三目並べなら十分ですが、「マウスの動きを全員に送る」ようなものは、すぐに上限に近づきそうです。

③ 同じ名前を使うと?

名簿のキーに name を使っています。同じ名前の人が2人入ったらどうなるかは、試していません。本番では、ログインしたユーザーのIDなど、重ならないキーを使うのが無難だと思います。

まとめ

  • Realtimeは、1枚のHTMLから試せる。Broadcast(放送)とPresence(出席簿)の2つが基本
  • タブを閉じると、名簿から1〜3秒で消えた(私の環境で、1回の観測)
  • つまずき:Broadcastは過去を覚えていない。途中参加の人には、「今の状態を教えて」と聞く工夫が要る
  • 公開チャンネルは、鍵を知っていれば誰でも入れる。本番ではプライベートチャンネルを検討(私は未検証)
  • 無料プランには、同時接続・イベント数の上限がある

次は、画像を自動で小さくして、軽い更新システムをつくる話です。

Leave a Reply

Your email address will not be published. Required fields are marked *