
【夏休みの自由研究リレー】 今さらながら WebRTC SFU が面白そうだったので、ボイスチャットつき4人対戦リバーシを作ってみた
こんにちは。
読書感想文は夏休み最終日に
運用イノベーション部のかわいです。
当記事は、クラスメソッドの有志による 『夏休みの自由研究リレー』 第17回目のエントリ です。
このブログリレーの企画は、普段からクラウドやAIを追いかけているメンバーによって、「やってみた」だけではなく「作ってみた」や「調査/研究してみた」もアウトプットしてみようという企画です!
新たな知見になることは勿論、アイデアを自社やコミュニティの発展に寄与できればと考えておりますので、お付き合いいただけますと幸いです!
今回の記事は、「今さらながら WebRTC SFU が面白そうだったので、ボイスチャットつき4人対戦リバーシを作ってみた」です!

WebRTC SFU とは?
(ご存知の方は読み飛ばしてね)
まず WebRTC(Web Real-Time Communication) ですが、これはブラウザ上で音声などリアルタイム通信を可能にする技術です。
WebRTC は、ブラウザ間の P2P 通信を実現する技術として広く知られています。
付随する仕組みとして、STUN サーバでグローバルIPとポートを教えてもらい、UDP ホールパンチングで NAT 越えさせたり、TURN サーバ経由でリレーさせたりといった流れが一般的です。
ただ、この技術は1対1通話ならシンプルに実現できますが、3人以上が同時に参加する通話(いわゆるマルチパーティ通話)になると考えることが増えます。
例えば P2P だと、各参加者が他の全員に対して個別にコネクションを張るメッシュ型の構成になり、参加人数が増えるほど、各クライアントの送信帯域や CPU 負荷にも繋がります。
先日たまたま目にした記事で、あるコンシューマー向けボイスチャットサービスが、今回取り上げるSFU*を採用していることを知りました。
(*Selective Forwarding Unit の略)
気になったので調べてみると、マルチパーティ通話を実現する方式には大きく分けて以下の3種類があるようでした。
- メッシュ型: 前述の、全員が全員と直接 P2P 接続する方法。実装はシンプルだが、人数が増えると各クライアントの負荷が増えるデメリットがある。
- SFU(Selective Forwarding Unit): サーバが各クライアントの音声/映像ストリームを受け取り、それを他の参加者へ転送するのみの中継役として動作する。サーバはデコード/エンコードをしないので負荷が軽減される。
- MCU(Multipoint Control Unit): サーバ側で複数ストリームを合成してから配信する技術。クライアント側の負荷は下がるが、サーバ側でのエンコード処理が必要になるため負荷に繋がることがある。
昔からあるメッシュ型はよく目にしていた一方で、(筆者不勉強のため)SFU は詳しく知りませんでした(汗
今回、サーバが音声等を転送するのみ、というシンプルな発想が面白そうだと思ったのと、世間一般のボイスチャット機能サービスにも取り入れられていると知り興味が沸いたので、自由研究の題材としてみました!
OSS の mediasoup で最小構成の SFU サーバを作成してみる
今回は Node.js 製 OSS ライブラリ「mediasoup」を使いました。
mediasoup 自体はシグナリングプロトコルを持たないライブラリで、WebSocket などのシグナリング経路は自前で実装する必要があります。
公式がmediasoup-demoというデモを公開しているので、今回はまずこの構造を参考に、ボイスチャット部分だけを抜き出し、稚拙ながら自前のシグナリングサーバをスクラッチで書き直す、という方法で進めました。
また、ボイスチャットだけだとシンプルすぎるので、今回はテキストチャットに加え、4人対戦できるリバーシゲーム機能を追加します。
▼完成品

基本構造について
インフラはコストを爆裂最小限に検証するため EC2 インスタンス1台のみ。
ファイルツリーは以下のシンプル構成。
sfu-demo/ # プロジェクトルート
├── package.json # 依存パッケージ定義
├── node_modules/ # インストール済みライブラリ本体
│ ├── mediasoup/ # SFU
│ └── ws/ # WebSocket サーバライブラリ
│
├── server.mjs # シグナリングサーバ本体
│ # Worker/Router初期化、WSS/HTTPS の待受・
│ # Producer/Consumer管理、トークン認証
│
├── reversi.mjs # 盤面ロジック
│
└── client.html # ブラウザに配信する静的HTML/JavaScript
# server.mjsが静的配信
mediasoup は以下の階層構造でメディアを扱います。
- Worker 実体はC++で書かれた別プロセス。1プロセスあたり1コアを使う。
- Router Worker内に作る論理的な部屋(ルーム)の単位
- WebRtcTransport Router内で、各クライアントとの間に張るメディア伝送路


サーバ側ログでは、接続・切断した人数がそのまま出力されます。
# 2人参加時
$ sudo -E $(which node) server.mjs
signaling server listening on :443
peer connected: 1785290148343-gm3mm4 (total: 1) # 1人目
peer connected: 1785290150085-c4dqhn (total: 2) # 2人め
peer disconnected: 1785290150085-c4dqhn (total: 1) # 1人離脱
peer disconnected: 1785290148343-gm3mm4 (total: 0) # 2人離脱
送信時/受信時の挙動について

mediasoup には、音声などのメディアを送る側を表すProducer、受け取る側を表すConsumerというオブジェクトがあります。
それぞれをサーバ側に作ってもらうための手続きがproduce(送信の開始)とconsume(受信の開始)です。
produceとconsumeで、誰が最初にアクションを起こすか?が逆になる点を理解するのに少し時間がかかりました。
流れとしては、
- produce(自分の音声を送信)はクライアント側トリガーとなります。 クライアントが
transport.produce()を呼び出し、そのリクエストをサーバに送信 → サーバ側にProducerを作ってもらう、という流れを取ります。自分が話を始めたいタイミングは当然クライアント側が知っているので、クライアントからサーバへ通信する形となる。 - consume(誰かの音声を受信)はサーバ側がトリガーとなります。新しい参加者Bが入ってきたとき、「Bは既存参加者Aの声をまだ受け取っていない」という状態を把握しているのはサーバだけなので、サーバが能動的に「Aの音声に対する Consumer をBのために作ったので受け取ってください」とBに通知します(ここで最初はハマりました)。
ここを誤って実装すると、「サーバから来るはずの通知をクライアント側で待たずに次の処理に進んでしまう」といった順序の不整合が生じます。
Whether the given RTP capabilities are valid to consume the given producer.(与えられたRTP capabilitiesが、与えられたproducerをconsumeするために有効かどうか)

もう1点として、Consumerを生成した直後paused: trueのまま作り、クライアント側の受け入れ準備が整うのを待ってからresume()で再開する、という方法があります。
When creating a consumer it's recommended to set
pausedtotrue, then transmit the consumer parameters to the consuming endpoint and, once the consuming endpoint has created its local side consumer, unpause the server side consumer using the resume() method.
(Consumerを作成する際は、pausedをtrueに設定することを推奨します。その後、Consumerのパラメータを受信側のエンドポイントに送信し、受信側がローカルのConsumerを作成した時点で、resume()メソッドを使ってサーバ側のConsumerの一時停止を解除します。)
今回、新規参加者への Consumer 生成時にまずpaused: trueで作り、クライアント側で<audio>要素の準備ができたタイミングでresumeConsumerメッセージを送り返す、という流れにしています。
実際のコードは、新規参加者Bが入ってきたタイミングで呼ばれるconsumeExistingProducers関数で、既にいる全員のProducerを1つずつ確認し、canConsumeでコーデック対応をチェックしてから、paused: trueでConsumerを作ってBに通知する流れを取っています(以下、一部コード抜粋)。
// 既存の全 Producer を、新規参加者に consume させる
async function consumeExistingProducers(newPeerId) {
const newPeer = peers.get(newPeerId);
for (const [peerId, peer] of peers) {
if (peerId === newPeerId || !peer.producer) continue;
if (!router.canConsume({ producerId: peer.producer.id, rtpCapabilities: newPeer.rtpCapabilities })) continue;
const consumer = await newPeer.recvTransport.consume({
producerId: peer.producer.id,
rtpCapabilities: newPeer.rtpCapabilities,
paused: true,
});
newPeer.consumers.set(consumer.id, consumer);
send(newPeer.ws, 'newConsumer', {
peerId,
producerId: peer.producer.id,
consumerId: consumer.id,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters,
});
}
}
このループの中でサーバが B に向けて送っているnewConsumerメッセージを受け取って初めて、クライアント側はrecvTransport.consume()でローカルのConsumerを作り、その後resumeConsumerを送り返す、という流れになります。
また、このproduce/consumeの非対称的なパターンは、音声だけでなくDataChannel(produceData/consumeData)でも全く同じ形で使用できます。
リバーシについて

あくまでもおまけ要素ですが、今回リバーシゲームを選んだ理由は、ルールがシンプルかつ、リアルタイムの多人数参加システムで必ず考慮が必要な要素が含まれているからです。
- 手番の管理(誰の番か?)
- 複数人の状態同期(盤面・スコアをどうブロードキャストするか?)
- 離脱時のリカバリ(誰かが抜けたときにゲームをどう継続するか?あるいは初期化するか?等)
あくまで mediasoup とは直接関連しませんが、WebSocket の send() method について勉強になりました。
また、盤面は2人なら8×8 かつ 2色の標準ルール、3人なら12×12 かつ 3色、4人なら16×16 かつ 4色というように、参加人数に応じて盤面サイズと色数を可変にしました。
石を挟む判定も標準ルールを単純に拡張し、8方向のいずれかで挟めるラインが成立すれば、途中に何色混在していても全て自分の色に反転する仕様にしています(そのほうが楽)。
盤面ロジックはreversi.mjsという別ファイルに切り出しました。
例▼
test('離脱者がちょうど手番のときならスキップ。ゲームを止めるな', () => {
const g = makeGame();
g.peers.set('p1', {});
g.peers.set('p2', {});
g.peers.set('p3', {}); // 離脱後も1人残す(全員離脱リセットと区別)
g.reversiAssignColorAndMaybeStart('p1');
g.reversiAssignColorAndMaybeStart('p2');
g.fireCountdown();
assert.equal(g.reversi.turn, 1);
// p1 の手番中に p1 が離脱(close イベント: peers.delete → reversiHandlePeerLeft の順を再現)
g.peers.delete('p1');
g.reversiHandlePeerLeft('p1');
// 手番が色2(p2)まで進んでいること(止まっていないこと)を確認
assert.equal(g.reversi.turn, 2);
assert.equal(g.reversi.finished, false);
});
(おまけ)入室待機タイマーの導入
最初は「2人揃ったら即開始」というシンプルな仕様にしていましたが、これだと3人目、4人目が入ってくるタイミングを取れないという問題が発生しました(3人目以降が何もできない)。そこで、2人以上の参加表明があった時点で15秒のカウントダウンを開始し、その間に集まった人数でゲームを開始するタイマー方式に変更しました。
EC2 上、sudo で実行時に Node.js が見つからない問題
話は逸れますが、EC2(AmazonLinux 2023)上から 443 番ポート(wss://)でLISTENさせようとしたところ、いきなり詰まったので以下備忘録。
# エラー内容
$ sudo -E node server.mjs
sudo: node: command not found
nvm でインストールした Node.js が、sudo を付けるとコマンドが見つからない事象が発生しました。
調べると、以下の点が分かりました。
- sudo は実行時、自分の
$PATH環境変数を見なず、/etc/sudoersに定義されたsecure_pathという固定のパスリスト(/usr/bin:/usr/sbinなど)だけ参照する - nvm でインストールした Node.js は
~/.nvm/versions/node/vX.X.X/bin/のようなパスにあり、secure_pathには含まれていないため見つからない -Eオプションは環境変数を引き継ぐためのもので、$PATH解決の仕組みとは無関係(ここを最初勘違いしていた)
回避策として、which nodeでフルパスを特定し、以下のように実行することでbindできました。
$ sudo -E $(which node) server.mjs
signaling server listening on :443
$(which node)はシェルが先に自分の$PATHでフルパスに展開してから sudo に渡すため、結果的にsecure_pathの制約を素通りします(※ただしこの方法は一時的な回避策なので注意)。
実環境で推奨される代替策としては、以下が挙げられます。
sudo setcap 'cap_net_bind_service=+ep' $(which node)— Node.js バイナリ自体に権限を付与し、一般ユーザーのままbindできるようにする(nvm でバージョンを上げるたびに再設定が必要)- nginx などのリバースプロキシを 443 で立て、Node.js 側はポート3000等で待ち受け
振り返りとまとめ的な
- WebRTC の基礎は知っていたつもりだったが、実際にSFUサーバを自分で組んでみると、より内部的な挙動が実際の動きを見ながら理解できてよかった
- 音声だけでなく、DataChannel でも同じ produce/consume パターンを使用できる
- ネットワークまわりからフロントまで触れて楽しかった
また、今回のコード自体は完全に個人検証用で EIP やポート番号を決め打ちしている箇所も多いため、コード等は最小限の掲載としています。同じ構成を試したい方は、mediasoup 公式のサンプルリポジトリ(mediasoup-demo)から始めるのが早いと思います。
以上、『夏休みの自由研究リレー』第17回のエントリ『今さらながら WebRTC SFU が面白そうだったので、ボイスチャットつき4人対戦リバーシを作ってみた』でした!
次回は村田さんの認証まわりを調査してみたお話です!
お楽しみに!!





