0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Last updated at Posted at 2026-10-23

対象: 「画面を更新しなくても、相手の操作が勝手に反映される」仕組みを、自分のアプリに入れてみたい方
所要時間: 30〜40分 / 使うもの: Supabaseの無料プラン、ブラウザ、ターミナル(簡単なサーバーを1つ立てます)
続きの記事(このしくみを、実際のWebアプリにしてみた): https://qiita.com/tomozilla/items/9f53279fbb2211bae503
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/n79d740dc996d

オンラインの対戦ゲームでも、共同で使うホワイトボードでも、「相手が動いたら、こちらの画面がすぐ変わる」ってありますよね。あれをつくるには、サーバーを自前で立てて、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は過去を覚えていない。途中参加の人には、「今の状態を教えて」と聞く工夫が要る
  • 公開チャンネルは、鍵を知っていれば誰でも入れる。本番ではプライベートチャンネルを検討(私は未検証)
  • 無料プランには、同時接続・イベント数の上限がある

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


スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?