SupabaseのBroadcastとPresenceを使ってオンライン対戦ゲームを実装してみる

## はじめに

Supabaseのリアルタイム機能と言えばPostgresのデータ変更を監視する「Postgres Changes」が多いですが、「Broadcast」と「Presence」を用いてリアルタイム機能を実現することも可能です。本記事では、当該の二つの機能を使ってオンライン対戦ゲームを実装した際の覚書を記述します。

## 成果物
今回作成したのはマインスイーパーの対戦ゲームです。個人で遊ぶモードと対戦モードの二種類があります。対戦モードではリンクをコピーして対戦相手にURLを共有することで対戦が開始できます。

https://github.com/esttom/vs-minesweeper

## Broadcast・Presenceとは

Supabaseにはリアルタイムに処理を行うための機能として以下の3つの機能が存在しています。

- `Postgres Changes`
Postgresデータベースの変更をリッスンして承認したクライアントに送信する機能。行レベルのセキュリティ（RLS）がセキュリティの基本。（※行ごとにポリシーを設定して権限を設定する方法）
- `Broadcast`
クライアントからクライアントにメッセージを低遅延で送信する機能。チャンネルのトピックを作成し、同一のトピックを持つクライアントにメッセージを送信。
- `Presence`
接続されたクライアント間でユーザの状態を共有する機能。クライアント切断の検知が可能。

Postgres ChangesとBroadcast・Presenceのどちらを用いてもある程度同じようなリアルタイムの機能は実現可能ですが、Postgres ChangesはRLSに基づいているためログインが前提になる（執筆時点では匿名ログインがない）ことと切断の検知ができない点においてBroadCast・Presenceの方が優位性があります。逆にデータを残しておきたい、より厳密なセキュリティが必要などの場合においてはPostgres Changesの方が良いかもしれません。

BroadcastとPresenceの使い分けとしてはPresenceがユーザ（あるいはオンラインのルーム）の状態（接続・切断）の管理、Broadcastがメッセージの送信を担当するという形になります。

## 実装方法

[公式](https://supabase.com/docs/guides/realtime)のガイドにまとまっているため、詳細はそちらをご参照ください。また、ライブラリのインストールなどは別途行ってください。

### クライアントの作成

機能を利用するためにはクライアントの作成が必要です。SupabaseのプロジェクトのページでURLとanon-keyが記載してあるので値を設定してください。（環境変数として.envに記述することを推奨します）
クライアントのインスタンスは都度作成するのではなく、1アプリケーションに1つのインスタンスとなるように作成します。

```js
import { createClient } from '@supabase/supabase-js'

const clientA = createClient(
  'https://<project>.supabase.co',
  '<your-anon-key>'
)
```

### チャンネルの作成
トピック、つまりデータを共有したい単位にチャンネルをそれぞれ作成します。下記の例では`room-1`のチャンネルのユーザは`room1`のデータを取得することができ、`room-2`のデータは取得できません。ちなみに`realtime`というチャンネル名は予約語なため利用できません。

```js
const channelA = clientA.channel('room-1')
const channelB = clientA.channel('room-2')
```

### Broadcast
#### 登録・送信
作成したチャンネルに登録するには`subscribe`を行います。登録後には同一チャンネルに`send`を用いてメッセージを送信が可能です。`send`のパラメータである`type`は固定、`event`は後述する受信処理と同じイベント名とします。`payload`は送信パラメータになり受信側にデータが渡されます。

```js
channelA.subscribe((status) => {
  if (status === 'SUBSCRIBED') {
    channelA.send({
      type: 'broadcast',
      event: 'test',
      payload: {
        message: 'hello, world'
      },
    })
  }
})
```

:::note warn
Broadcastでの送信は送信者本人を含みません。
必要な場合はチャンネル作成時に以下のオプションを指定してください。
```js
clientA.channel('room-1', {
  config: {
    broadcast: {
      self: true,
    },
  },
})
```
:::

#### 受信
送信したデータを受信する場合は`on`でイベントハンドラーを記述します。イベントは送信時と同一のものにする必要があり、複数のイベントを登録可能です。（送受信でそれぞれチャンネルをsubscribeする必要はなく一度のみです）

```js
channelA
  .on(
    'broadcast',
    { event: 'test' },
    (payload) => console.log(payload)
  )
  .subscribe()

```

### Presence
Broadcastと同様にチャンネルに`subscribe`して`track`を行うことで状態の追跡・同期が可能になります。追跡可能なイベントは参加を表す`join`、退出を表す`leave`、そして状態が変更された際に発火する`sync`の三種類となります。`track`の際には任意のデータを詰めることが出来るため管理したいデータがあれば別途詰め込みましょう。

```js
channelA
  .on('presence',
    { event: 'sync' },
    () => {
      const newState = channelA.presenceState()
      console.log('sync', newState)
    }
  )
  .on('presence',
    { event: 'join' },
    ({ key, newPresences }) => {
      console.log('join', key, newPresences)
    }
  )
  .on('presence',
    { event: 'leave' },
    ({ key, leftPresences }) => {
      console.log('leave', key, leftPresences)
    }
  )
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      const presenceTrackStatus = await channelA.track({
        user: 'user-1',
        online_at: new Date().toISOString(),
      })
      console.log(presenceTrackStatus)
    }
  })
```

上記によりブラウザを閉じた場合などの切断を検知可能ですが、明示的にチャンネルから退出する際は`untrack`を実行します。これにより`leave`と関連して`sync`イベントが他のユーザにおいて発火します。

```js
const presenceUntrackStatus = await channelB.untrack()
console.log(presenceUntrackStatus)
```

## 実装時に困ったこと

### Broadcastでメッセージを連続送信できない

Broadcast、Presenceにはデフォルトで送信制限がかかっており、100msに1メッセージのみとなっています。連続で送信する際には間隔を空けるか、クライアント作成時に以下のパラメータを変更しましょう。（下記では秒間20メッセージ、50msに1メッセージ）
```js
const clientA = createClient('https://<project>.supabase.co', '<your-anon-key>', {
  realtime: {
    params: {
      eventsPerSecond: 20,
    },
  },
})
```

### Presenceの入室制限

サンプルで作成したアプリケーションは一対一のマインスイーパーであるため二人に制限する必要があるのですが、Presenceでは入室制限は設定できません。入室した際には`sync`が呼び出され現在の人数を確認可能なため、指定の人数以上いる場合はチャンネルから退出させる、エラーメッセージを表示するなどの対応は可能です。

## 雑感
Postgresのデータ監視ではテーブル構成やRLSに悩まされることが多かったのですが、BroadcastやPresenceはデータの送受信の対象が直観的でかつ、切断検知などの機能もあり、リッチなアプリケーション実現に利用できそうだと感じました。

## 参考
Supabase realtime
https://supabase.com/docs/guides/realtime