個人サイトのブラウザゲームに、サーバーを1台も借りずにオンライン対戦を付けた

2026-08-01

自作の3D対戦ミニゲーム「サルモノハオトス」に、オンライン対戦を付けました。合言葉を4文字決めれば、友だちとその場で対戦できます。

作る前にいちばん引っかかっていたのは、技術というより身の丈の話でした。個人サイトのミニゲームひとつのために、24時間動き続けるサーバーを持つのか、と。

NPC戦は動いた。「友だちと対戦」にした瞬間、話が変わった

もともとこのゲームは、ブラウザの中だけで完結していました。プレイヤーとNPCが浮き島の上でマスを崩し合う。盤面はブラウザが持っていて、それで何も困りません。

ところが2人の人間が別々の端末から遊ぶとなると、途端に困ります。二人が同時に同じマスへ踏み込んだとき、どちらが先に着いたことにするのか。片方の画面では自分が勝っていて、もう片方では相手が勝っている、という状態を防げません。

つまり「正しい盤面はこれです」と言い切る役、権威が要る。そしてそれは、どちらのブラウザでもない場所に置く必要があります。

常時起動のサーバーもP2Pも選ばなかった

素直な選択肢はVPSを1台借りて、そこでNodeとWebSocketサーバーを動かすことです。やり方は完全に確立していますし、情報も多い。

ただ、うちのサイトの規模を考えると、誰も対戦していない時間のほうが圧倒的に長い。その大半を、ただプロセスが起きているだけの状態で過ごすことになります。加えて、動き続けるものは面倒を見続ける必要がある。ゲームを作りたくて始めたのに、サーバーの世話が主な仕事になるのは避けたいところでした。

ブラウザ同士を直結するWebRTCも考えました。こちらは中央にサーバーが要らないのが魅力です。ただ、相手を見つけるための仲介役はどのみち必要ですし、何より「権威をどこに置くか」という最初の問題が形を変えて戻ってくる。決定論的に同期させる方法も、片方をホスト扱いする方法もありますが、今回はそこに頭を使いたくありませんでした。単一の権威を、いちばん単純に置きたかった。

そこで Cloudflare の Durable Objects を使いました。すごく雑に言うと、部屋ごとに小さなサーバーが勝手に立ち上がって、誰もいなくなったら畳まれる仕組みです。対戦が始まっていない時間に、動き続けているものがない。今回いちばん欲しかったのは、まさにこの性質でした。

部屋がひとつのオブジェクトになる

構成はWorkerがひとつと、Durable Objectが2種類です。

Workerの本体は驚くほど薄くて、やっているのはルーティングだけです。リクエストの中身は、ロビーか対戦ルームのどちらかに丸ごと渡します。

[durable_objects]
bindings = [
  { name = "LOBBY", class_name = "FightLobby" },
  { name = "ROOM",  class_name = "FightRoom" }
]

Lobby DOは、ルームIDを発行する唯一の場所であり、部屋一覧を書き換える唯一の場所でもあります。Room DOは1試合分の権威で、参加者のWebSocketを持ち、盤面を進めます。

ここで効いてくるのが、Durable Objectは同じIDに対して世界にひとつしか存在しない、という性質です。部屋一覧を書き換えるのが常にひとつのオブジェクトなら、ふたりが同時に部屋を作りにきても、書き込みが混線しません。ロックも、トランザクションも、自分では書いていません。並行処理の面倒な部分を、置き場所の設計で回避した格好です。

ルームIDは4文字にしました。声に出して伝えられる長さで、紛らわしい文字(0とO、1とIなど)は候補から抜いてあります。ただしこれは合言葉であって、資格情報ではありません。あとから再接続してくる人が本人かどうかは、別の値で確かめています。

クライアントは何も計算していない

サーバーが権威だと決めたので、ブラウザ側は徹底して表示専用にしました。オンライン対戦時のコントローラは、更新関数が空っぽです。

export function createRemoteFightController(
  viewerId: string,
  initial: ControllerSnapshot
): RemoteFightController {
  let snapshot = initial
  // ...
  return {
    // 派生情報はローカル対戦と同じ純関数から取り出す
    getWarningCells: () => snapshotView(snapshot, viewerId).warningCells,
    getInvincibleIds: () => snapshotView(snapshot, viewerId).invincibleIds,
    // シミュレーションはサーバー側。ここでは何もしない
    update: (_dtMs: number, _input: StepInput) => {},
    pushState: (next, events) => { snapshot = next }
  }
}

地味ですが、この update() が空であることが設計の芯だと思っています。ブラウザは受け取った盤面を映すだけ。予測もしないし、辻褄合わせもしない。

そのうえで、盤面から表示用の情報を取り出す snapshotView は、ローカル対戦とオンラインで同じものを使っています。「どのマスが警告色か」「誰が無敵中か」といった判定を二重に実装していないので、片方だけ直し忘れて表示がずれる、という事故が起きません。

Durable Objectの中に置いたのは、ループと後始末だけ

Durable Objectはテストが書きづらい部類のコードです。ストレージもアラームもWebSocketも、実行環境そのものに依存しています。

なので、判断を伴う部分は全部外に出しました。席の予約やフェーズの遷移はDOに依存しない純関数の集まりで、盤面を1ティック進める処理はブラウザ版と共用しているエンジンの関数です。DOに残ったのは、タイマーを回すこと、状態を保存すること、参加者に配ることだけ。

// DO非依存の純粋なルーム状態機械。
// Room DO(Durable Object)はここの遷移関数を呼ぶだけにして、ロジックはvitestで検証する。

これは room-logic.ts の冒頭に自分で書いたコメントですが、方針としてはこの一行に尽きます。おかげでテストは普通に速くて、ゲーム関連だけで584件、サイト全体では1354件が2.5秒ほどで終わります。ここを守っておかないと、通信まわりの実装は手が出しづらくなっていたと思います。

50msで進めて、実際には100msごとに配っていた

対戦中のループは50ミリ秒間隔です。ただ setInterval は指定どおりの間隔では回ってくれないので、経過時間は実測を使っています。

// タイマードリフトに頑健なよう、実経過時間を累積してdtにする
const now = Date.now()
const dtMs = now - this.lastTickAt
this.lastTickAt = now

そして配信は毎ティックではなく、間引いています。この記事を書くために自分のコードを読み返して、そこで気づいたことがありました。

this.broadcastAccumMs += dtMs
if (this.broadcastAccumMs >= BROADCAST_MS) {   // BROADCAST_MS = 66 // 15Hz
  this.broadcastAccumMs = 0
  this.broadcast({ t: 'state', snapshot: result.snapshot, events: this.pendingEvents })
}

定数には 66 // 15Hz と書いてあります。書いた本人はそのつもりでした。でも実際は違います。50ミリ秒のティックで累積していくと、1回目は50で閾値に届かず、2回目の100で初めて配信されます。そして累積を66引くのではなく0に戻しているので、次もまた50、100と積み直す。結局ずっと100ミリ秒ごと、およそ10Hzです。

面白いのは、公開してから今まで誰にも指摘されず、自分で遊んでいても違和感がなかったことです。理由はゲームの作りにあります。このゲームは格子の上で動きます。キャラの位置は8×8マスの整数で、途中の座標というものがない。連続した位置を追いかけるアクションゲームなら10Hzは論外ですが、マス単位なら1秒に10回で足ります。

そして見た目の滑らかさは、通信とは別のところで作っています。描画側では、論理的なマス位置を目標にして、毎フレーム指数減衰で近づけているだけです。

// フレームレート非依存の指数減衰補間。halfLifeMsごとに残距離が半分になる。
export function dampenValue(current: number, target: number, dtMs: number, halfLifeMs: number): number {
  const factor = 1 - Math.pow(0.5, dtMs / halfLifeMs)
  return current + (target - current) * factor
}

キャラの追従は70ミリ秒、タイルの沈み込みは110ミリ秒を半減期にしてあります。通信が10Hzでも、画面は60fpsで滑らかに動く。通信レートと見た目の気持ちよさは、そもそも別の問題だったわけですね。

とはいえ意図と実装がずれているのは事実なので、ここは直します。ただ本番で動いているサーバーの挙動を変える話なので、記事とは切り離して、実際に対戦しながら確かめるつもりです。

抜けた人はサルのまま残しておく

対戦中に誰かの回線が切れたとき、どうするか。試合を止めるのがいちばん簡単ですが、残されたほうは何もしていないのに終わらされます。

なので、切断した席は30秒待ってからNPCに引き継がせることにしました。実装はこうです。

// エンジンの契約に従い、bot引き継ぎ=IDをhuman系Recordからnpc系Recordへ移す操作
export function takeOverWithBot(snapshot: ControllerSnapshot, id: string): ControllerSnapshot {
  const humanCooldownMs = { ...snapshot.humanCooldownMs }
  delete humanCooldownMs[id]
  return {
    ...snapshot,
    humanCooldownMs,
    npcCooldownMs: { ...snapshot.npcCooldownMs, [id]: NPC_TICK_MS_BY_DIFFICULTY[snapshot.difficulty] }
  }
}

やっているのは、キャラのIDを人間側のRecordからNPC側のRecordへ引っ越しさせることだけです(実際には移動と攻撃で2つのクールダウンを扱っています)。エンジンは「人間とは humanCooldownMs にキーがある者のことだ」という契約で書いてあるので、キーの置き場所を変えるだけで、そのキャラは次のティックから勝手に動きはじめます。

再接続したときは、まったく同じ操作を逆向きにやります。人間かNPCかという、いかにも大がかりに聞こえる区別が、Recordのどちらにキーがあるかという一点に還元されている。個人的に、この実装がいちばん気持ちよかった部分です。

ちなみに両方とも切断した場合は待ちません。その場で試合を畳みます。誰も見ていない試合を回し続ける理由がないからです。

同時に同じマスへ来た2人を、どちらから処理するか

サーバーは1ティックの中で、参加者の入力を順番に適用します。ということは、先に処理された人が有利になります。同じマスを取り合ったとき、常に先手の側が勝つ。

対策は拍子抜けするほど単純で、順番を毎回ずらしています。

// 各人間の行動。逐次適用による先行者有利(衝突)を平均化するため、適用順をティックごとに回す
const humanIds = Object.keys(humanCooldownMs)
const rotation = humanIds.length > 0 ? snapshot.tick % humanIds.length : 0
const orderedHumanIds = [...humanIds.slice(rotation), ...humanIds.slice(0, rotation)]

同時押しをきれいに裁く方法ではありません。有利不利を両者に均等にばらまいているだけです。ただ、1秒に20回入れ替わるので、体感としては偏りが消えます。

入力の送り方も少し工夫していて、方向は変わったときだけ送っています。押しっぱなしの間に同じ方向を送り続けても意味がないからです。

消したはずの部屋が、一覧に生き返る

いちばん厄介だったのはここでした。

ロビーの部屋一覧は、各部屋が30秒ごとに送ってくる心拍で維持しています。90秒届かなくなった部屋は、落ちたものとみなして一覧から消す。ありふれたリース方式です。

問題は、部屋を削除した直後に、削除前に送られた心拍が遅れて届いたときです。ロビーから見れば「知らない部屋から心拍が来た」ので、律儀に一覧へ載せ直してしまう。消したはずの部屋が復活し、入ろうとすると当然どこにも繋がらない。

対策として、削除した部屋のIDを2分間だけ墓標として覚えておくことにしました。この間に届いた心拍は、どれだけ正しい形をしていても無視します。

ただし、この墓標はメモリの上にしかありません。ロビーのオブジェクトが入れ替われば消えます。実害としては、ちょうどその瞬間に迷子の心拍が届けば同じことが起きるわけで、完全ではない。今のところ2分あれば足りていますが、正直に書いておきます。

「Durable」という名前ほどは、勝手に続かない

名前から誤解していたことがあります。Durable Objectは状態を保存してくれますが、動きまで復元してくれるわけではありません。

盤面はストレージに書いてあるので消えません。でもオブジェクトが起き直ったとき、対戦ループのタイマーが自動で回りはじめるわけではないし、心拍も止まったまま、WebSocketの接続もありません。持続するのはデータであって、実行ではない。ここは実装しながら理解が更新されたところでした。

デプロイの順番を間違えて、自分で自分を切断した

最後に、実際にやらかした話をひとつ。

ルーム内チャットを追加したとき、サーバーとページの両方を更新する必要がありました。ページを先に出してしまったんですね。すると、新しいページを開いた人がチャットのメッセージを送る、まだ更新されていないサーバーはそんなメッセージを知らない、不正なメッセージとみなして接続を切る、という流れになります。自分で自分を追い出しました。

順番はサーバーが先、ページが後です。新しいサーバーは古いメッセージも受け取れますが、逆は成立しない。個人開発でクライアントとサーバーを同じリポジトリで書いていると、両方まとめて出したくなるのですが、出す順番には向きがあります。

今できること、これから

現在は2人対戦です。合言葉で部屋を作って共有すれば、そのまま対戦できます。決着したあとも5分ほど部屋は残るので、チャットで健闘を称え合えます。

次は3〜4人での対戦、観戦、そして再戦をそのままの部屋でできるようにしたいと考えています。参加人数を増やすと、さきほどの順番ローテーションのような話がまた出てくるはずで、そこは実際に人を増やしてみないと分からない部分です。

遊んでみたい方はこちらからどうぞ。ダウンロードは要りません。

このサイトについて(少しだけ)

SCGF.NETでは自作ゲームを公開しつつ、こうした技術まわりの記録も書いています。作りながら分かったこと、間違えたことをそのまま残していくつもりです。テック系の記事は #テック タグからまとめて読めます。