はじめに
メタライフが有料化するらしいです…
まじか…
会社内の研修を通じて社内外のメタライフコミュニティがあったのでこの事実を知ったときは結構ショックでした。
じゃあもう俺が無料版メタライフみたいなものを作るしかないと考えてオンラインバーチャルオフィスを作成しました。
しかもこれ、動かしっぱなしで料金は月およそ0円で回っています。「サーバーレスで音声通話とか無理でしょ」と思いますよね。作る前は私もそう思っていました(笑)。この記事では、その"無理そうなこと"がなぜ成立するのかを、できるだけかみ砕いて書いていきます。
(本文に出てくる数字や仕様は、ぜんぶ実際に動いているコードそのままです。)
どんなものを作ったのか
ざっくりこんなやつです。
- ドット絵のアバターを、矢印キー / WASD でテコテコ歩かせる
- 他のプレイヤーに一定距離まで近づくと、ボタンを押さなくても勝手に通話が始まる
- 離れると、勝手に切れる
- 近いほど声が大きく、遠いほど小さく聞こえる(ゲームのボイチャでよくあるアレ)
- ミュートボタンあり
- 合言葉を知っている人だけが入れる、身内向けの小部屋
入口はこんな感じ。合言葉と表示名を入れて「入室する」を押すだけ。
そして中に入ると、こう。
見ての通り、完全にゲームです。でも中身は真面目に WebRTC で音声通話しています。左上の「発話 ◯人 / 聞き専 ◯人」は、あとで説明する"接続枠"のカウンターです。
技術スタック(パーティ編成)
使ったものを一覧にするとこう。
| 担当 | 使った技術 | 役割 |
|---|---|---|
| フロント&ゲーム画面 | Vite + TypeScript + Phaser 3.90.0 | 2Dマップの描画・アバター移動・当たり判定 |
| リアルタイム同期 | Cloudflare Workers + Durable Objects | 「誰がどこにいるか」を全員に配る係 |
| 音声通話 | WebRTC(simple-peer) |
ブラウザ同士が直接、音声をやり取り |
| NAT越え | Cloudflare Realtime(STUN / TURN) | ファイアウォール越しでも繋げるための保険 |
| ホスティング | Cloudflare Pages | HTML / JS の配信 |
見事に全部 Cloudflare に寄せてます。理由は単純で、無料枠がめちゃくちゃ太っ腹だから。個人のちょい作りツールなら、これでほぼ全部タダで済んでしまう。いい時代です。
全体のしくみ:通信ルートは、じつは2種類だけ
まず全体像。今回いちばん伝えたいのは、この1枚です。
ごちゃっと見えますが、覚えることは 「通信ルートが2種類ある」 だけです。
- ① ページの読み込み:Cloudflare Pages から HTML と JS をただ配るだけ。ここは普通のWebサイトと一緒。
- ② WebSocket(制御用):「誰がどの座標にいるか」「入った / 出た」「ミュートしてる?」みたいな軽い情報だけを、Worker 経由で全員に配る。
- ③ WebRTC P2P(音声そのもの):実際の声のデータは、Worker を一切通らず、ブラウザ同士が直接飛ばし合う。ここがキモ。
- ④ TURN中継(保険):③がどうしても直接繋がらない時"だけ"、Cloudflare の中継所を間に挟む。
なんで音声だけ、こんな特別扱いなの? というのが次の話です。
なぜ音声はサーバーに通さないのか
もし音声も全部サーバー経由(SFU という方式)にしたらどうなるか。30人が同時に喋ったら、サーバーは30人分の声を受け取って、また30人に配り直すことになります。帯域もサーバー代も、人が増えるほどモリモリ膨らむ。個人の無料枠では、まあ即死です。
一方、WebRTC の P2P なら、声はブラウザとブラウザの間を直接ワープします。サーバーの仕事は「誰と誰が近いか」という軽い情報を配るだけ。だから帯域コストがほぼ0。これが「月ほぼ0円」の最大のカラクリです。
もちろんタダより高いものはなく、P2P にも弱点があります。参加人数が増えると、1人あたりの接続本数がどんどん増える。この対策は次の章で。
「近づいたら繋がる」の正体は、ただの距離くらべ
魔法みたいに聞こえますが、やってることは超シンプル。全員の座標から2点間の距離を出して、しきい値より近ければ「繋ぐ」、遠ければ「繋がない」。それだけ。
心臓部は、これくらいの短い純粋関数で書けます(実コードから抜粋)。
export const CONNECT_RADIUS = 150; // これ以内なら繋ぐ候補
export const DISCONNECT_RADIUS = 180; // ここまで離れて初めて切る(のりしろ)
export const VOLUME_FULL_RADIUS = 50; // これ以内は音量100%
export const SPEAKING_SLOT_LIMIT = 8; // 喋ってる相手と繋がれる上限
export const LISTEN_SLOT_LIMIT = 29; // ミュート中の相手を「聞くだけ」で繋がれる上限
export function computeVolume(distance: number): number {
if (distance <= VOLUME_FULL_RADIUS) return 1; // 50px以内は音量MAX
if (distance >= CONNECT_RADIUS) return 0; // 150px以上は無音
// 50→150pxのあいだは、距離に応じて音量を線形にしぼる
return 1 - (distance - VOLUME_FULL_RADIUS) / (CONNECT_RADIUS - VOLUME_FULL_RADIUS);
}
ここに、地味だけど効いてる工夫が3つあります。
工夫1:音量を距離で滑らかに変える
繋がってる / 繋がってないの0か1かだけじゃなく、Web Audio API の GainNode で 距離に応じて音量そのものをスーッと下げています。近づくと声が大きくなる、あの感覚はこれ。
工夫2:繋ぐ線と切る線を、わざとズラす(ヒステリシス)
境界ちょうどの距離をウロウロされると、悲惨です。「繋がった → 切れた → 繋がった → …」を毎フレーム繰り返して、通話がバチバチ点滅する。地獄。
なので、繋ぐしきい値(150px)と切るしきい値(180px)をわざと別の値にしてあります。一度繋がったら、ちょっとやそっと離れたくらいじゃ切れない。ドアに"のりしろ"を持たせるイメージ、と言えば伝わるでしょうか。
工夫3:人が集まっても破綻しないよう「接続枠」を設ける
さっき言った P2P の弱点です。近くの全員と無制限に繋ごうとすると、接続本数が爆発して CPU もネットワークも死にます。そこで上限を設定:喋ってる相手とは最大8人、ミュート中の相手を"聞くだけ"なら最大29人まで。あふれた人は「圏外」あつかいで、静かにお祈り(枠に入れず待機)。人が集まる場所でも重くならないための、現実的な妥協点です。
ちなみにこの判定、自分が「繋ぎたい」と思い、相手も「繋ぎたい」と思った時だけ実際に繋ぐ(片思いは成立しない)ように、両側の希望を突き合わせています。全員が同じ座標データを見て同じ計算をすれば、サーバーに判定させなくても、クライアント同士が勝手に同じ結論にたどり着く。地味に気持ちいいポイントです。
NAT越え(STUN / TURN)を一言で
「ブラウザ同士で直接繋ぐ」と軽く言いましたが、これが素直にいかない。多くの家庭やオフィスのPCは、ルーターの内側にいて、外から直接話しかけられません(NAT という仕組みのせい)。
郵便でたとえると、こう。
- STUN:「あなたの家(グローバルな住所)はここですよ」と教えてくれるだけの、軽いサービス。タダで無制限に使えます。
- TURN:STUN でもどうしても届かない(かなり厳しいファイアウォールの)相手のために、代わりに郵便局が中継してくれるサービス。中継する分だけ帯域を食うので、こっちは従量課金。
このアプリでは Cloudflare Realtime(月1,000GBまで無料)を、繋がらない時だけの保険として使っています。
そして地味に大事なのが鍵の扱い。TURN の認証情報は サーバー(Worker)側でだけ発行して、クライアントには「数時間だけ有効な使い捨ての鍵」しか渡しません。長期の秘密鍵をブラウザのJSに埋め込んだら、丸見えで盗まれ放題ですからね。それだけは絶対にダメ。
サーバーがほぼ無料で動く理由
「WebSocket で常時接続を受けるサーバーが、無料枠で回るわけ……」と思いますよね。ここで Cloudflare の Durable Objects が効いてきます。
Durable Object は、ざっくり「状態を持てる小さなサーバーが、必要な単位でポコっと立ち上がる」仕組み。今回は 1部屋 = 1つの Durable Object として、そこに在室者の座標やミュート状態を持たせています。
さらに強いのが WebSocket Hibernation API。接続が繋がりっぱなしでも、メッセージが飛んでいない間は課金対象の実行時間にカウントされない。みんなが黙って座標だけたまに送っているヒマな時間帯は、実質タダで維持できる。身内向けのゆるいツールだと、この特性がめちゃくちゃ効きます。
開発中に踏んだ罠(ここが本編)
……とまあ、ここまで「きれいに動いてます」みたいな顔で書いてきましたが、道中はふつうにバグまみれでした。特に記憶に残っている3体のボスを、ここで供養します。
罠1:ミュート解除、2回目で爆発する
ミュート → 解除、まではいい。もう一回ミュート → 解除。はい爆発。2回目の解除で例外が飛びます。
犯人は simple-peer の仕様でした。このライブラリ、一度取り除いたトラック(音声)は、二度と同じようには付け直させてくれない。頑固。素直に「ミュートで外す → 解除で付け直す」と書くと、この頑固さに正面衝突します。
対策は、simple-peer の親切な高レベルAPIをそっと閉じて、ブラウザ標準の RTCRtpSender.replaceTrack() を直接呼ぶ方式に変更。トラックの"中身"だけをすり替えるやり方です。これなら何回でも安全に呼べるうえ、通信のやり直し(再ネゴシエーション)も起きないので速い、というオマケ付き。前より良くなったので、まあ結果オーライ。
罠2:見えないところで、当たり判定だけ2倍になっていた
新しいタイルに合わせてアバターの絵を2倍サイズにしたんですが、ここに罠。Phaser で当たり判定(円)を作る Body.setCircle() は、拡大する"前"のサイズを基準に計算するという仕様でした。
つまり、絵は2倍・当たり判定「も」勝手に2倍、おまけに中心までズレる。画面上は完璧に見えるのに、当たり判定の円だけこっそり2倍という、実に陰湿な状態です。見た目だけ確認していたら、100年経っても気づけない。
じゃあどうやって気づいたか。壁にアバターをぶつけてみて、「……あれ? 思ったよりだいぶ手前で止まるな?」と。見えない壁に阻まれるアバターを眺めて、ようやく察しました。
罠3:「動いてるように見えて、実は届いてない」いちばん厄介なやつ
これがワースト。フロント(Pages)とバック(Worker)を別ドメインにデプロイしたら、TURN 認証情報を取りに行くURLを相対パスで書いていたせいで、リクエストが自分自身のページに向かって飛び、存在しないパスに404を返していました。
何がタチ悪いって、このアプリは「TURN が取れなかったら STUN だけで我慢する」という正しいフォールバックをちゃんと持っていたこと。おかげでバグっているのに、アプリは「あー、TURN まだ設定してないのね、了解ー」みたいな涼しい顔で STUN だけで動き続ける。本来の想定(TURN未設定)と、ただのバグ(URL間違い)が、画面上でまったく同じ見た目になってしまった。名探偵でも無理です。
エラーを握りつぶして自動で立ち直る設計は便利だけど、「実は別の原因で、こっそり失敗し続けている」のに気づけなくなる。優しさが仇になるパターンでした。いい教訓。
デプロイ(ここまで全部タダでできる)
最後に、実際のデプロイの流れをざっくり。
- Cloudflare にログインして、Worker をデプロイ(
wrangler deploy) - TURN を使うなら、ダッシュボードで TURN キーを発行
-
wrangler secret putで、合言葉や TURN の鍵を サーバー側だけ に登録(クライアントのコードには一切書かない) - フロントを
npm run build。このとき 接続先 Worker の URL を、ビルド時の環境変数として埋め込む - できた静的ファイルを Cloudflare Pages にデプロイ
秘密の鍵たちは、最初から最後までクライアントに渡らず、Worker 側だけに存在する——という点だけ、しつこく意識してあります。
まとめ
- 「近づいたら勝手に繋がる」は、距離くらべ + ヒステリシス + 接続枠の上限という、意外と地味なロジックの組み合わせで作れる
- 音声は P2P で直接飛ばし、サーバーには軽い座標情報だけを流すことで、個人開発でも 月ほぼ0円 に収まる
- STUN / TURN や Durable Objects みたいな"裏方"を知っておくと、リアルタイム系のアプリはグッと作りやすくなる
個人が、ほぼタダで、サーバーの面倒もほぼ見ずに、音声通話アプリを作れてしまう。いい時代になったなあ、というのが正直な感想です。同じようなものを作ってみたい人の、何かの参考になれば嬉しいです。



