対象: 「画面を更新しなくても、相手の操作が勝手に反映される」仕組みを、自分のアプリに入れてみたい方
所要時間: 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:「今だれが部屋にいるか」を共有する
つまずいたところ(途中から入った人の盤面が空っぽ)も、そのまま書きます。
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 を見ます。
ここに、あとで大事になる設定があります。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=Xhttp://127.0.0.1:8765/index.html?name=Bob&mark=O
どちらのタブにも、「オンライン 2人:Alice(X)、Bob(O)」と出ました。 画面を更新していないのに、相手が入ったことがわかります。これがPresenceです。
交互に打ってみる
Aliceのタブで真ん中、Bobのタブで左上、Aliceで右下、Bobで右上、と打ちました。結果は、どちらのタブも同じ盤面になっていました。
自分のタブで打った手は自分の画面に、相手のタブで打った手は、更新ボタンを押さなくてもこちらの画面に出ます。これがBroadcastです。
5. つまずき:途中から入った人の盤面が空っぽ
3つ目のタブで、観戦者を入れてみました。
http://127.0.0.1:8765/index.html?name=Carol
名簿は「オンライン 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にも、盤面が出ました。 プレイヤー(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つ選ぶと、中身が見えます。
{ "type": "broadcast", "event": "move", "payload": { "cell": 0, "mark": "X" } }
デバッグのとき、「送ったはずなのに届かない」を確かめるのに便利そうです。
8. 気をつけたいこと
① 公開チャンネルは、鍵を知っていれば誰でも入れる
さきほどの Allow public access to channels がオンの状態では、公開鍵(ブラウザに入れる鍵)を知っている人なら、誰でも game-room-1 に入って、メッセージを送れます(公開鍵は、ブラウザのコードを見れば誰でも分かります)。ゲームなら「偽の手を送られる」可能性があります。
対策として、プライベートチャンネル(ログインした人だけ、RLSのポリシーで許可した人だけ)に切り替える方法が用意されています。これは私は今回試していません。本番で使うなら、必ず検討したい部分です。
② 無料プランの上限
Realtime → Settings に、上限が表示されていました。
- 同時接続:200クライアント
- 1秒あたりのイベント数:100 events/s(無料プランでは、画面上は変更できない状態でした)
三目並べなら十分ですが、「マウスの動きを全員に送る」ようなものは、すぐに上限に近づきそうです。
③ 同じ名前を使うと?
名簿のキーに name を使っています。同じ名前の人が2人入ったらどうなるかは、試していません。本番では、ログインしたユーザーのIDなど、重ならないキーを使うのが無難だと思います。
まとめ
- Realtimeは、1枚のHTMLから試せる。Broadcast(放送)とPresence(出席簿)の2つが基本
- タブを閉じると、名簿から1〜3秒で消えた(私の環境で、1回の観測)
- つまずき:Broadcastは過去を覚えていない。途中参加の人には、「今の状態を教えて」と聞く工夫が要る
- 公開チャンネルは、鍵を知っていれば誰でも入れる。本番ではプライベートチャンネルを検討(私は未検証)
- 無料プランには、同時接続・イベント数の上限がある
次は、画像を自動で小さくして、軽い更新システムをつくる話です。
スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。









