マガジン

オンライン対戦の通信量を減らす:差分送信

遊びをつくる

執筆にClaudeを使用しています。

途切れた小さな橋をつなぎ直し、途中のチェス盤へ戻るための道を整える情景。
記事のテーマをもとに制作したAI生成の写真風イメージ。

オンライン対戦ゲームをブラウザで動かすと、「動きがカクつく」「スマートフォンで遊ぶとデータ通信量が気になる」という問題に早い段階でぶつかります。原因の多くは、毎フレームや毎ターンにゲーム全体の状態を丸ごと送り続けていることにあります。 この問題に対応する考え方が「差分送信(デルタ送信)」です。前回の送信から変わった部分だけを抽出してパケットに詰め、変化がなければ何も送らない——これだけで通信量は大幅に削減できます。WebSocketを使ったリアルタイム通信でもターン制のゲームでも原理は同じで、実装の難易度も決して高くはありません。 この記事では、差分送信の仕組みと実装上の判断基準を、ブラウザゲームに即した具体的なコードの考え方とともに説明します。

この記事の目次
  1. なぜ「全送信」は重いのか
  2. 差分送信の基本構造
  3. 判断基準:どこまで差分にするか
  4. 実装で失敗しやすいポイント
  5. バイナリ化・圧縮との組み合わせ
  6. 次の行動
  7. 参考資料

なぜ「全送信」は重いのか

ゲームの状態(ゲームステート)には、プレイヤーの座標・HP・スコア・フィールドの状態など多くのフィールドが含まれます。これを毎フレームJSON文字列にして送ると、たとえばフィールドが10×10のグリッドなら100マスの情報が毎回流れます。1秒間に20回送信するアクションゲームなら、変化がたった1マスであっても100マスぶんのデータを流し続けることになります。

接続人数が増えるほどサーバーの帯域コストも乗算的に増え、スマートフォンの4G/5G回線では通信遅延が突然跳ね上がることもあります。また、WebSocketの無料・低コスト枠はメッセージ数や転送バイト数で制限されている場合が多く、全送信は予想外のコスト超過を招きやすいです(各サービスの最新条件は公式サイトで確認してください)。

差分送信の基本構造

差分送信の考え方はシンプルです。

  1. 前回送信したステートのコピーを「前回スナップショット」として保持する。
  2. 現在のステートと前回スナップショットを比較し、変化したキーだけを抽出する。
  3. 抽出した差分オブジェクトを送信する。
  4. 受信側は受け取った差分を手元のステートにマージする。
  5. 送信側は「前回スナップショット」を現在のステートで上書きする。

JavaScriptで表現すると、差分の抽出は次のような関数として書けます。

js
function extractDelta(prev, next) {
  const delta = {};
  for (const key of Object.keys(next)) {
    if (JSON.stringify(prev[key]) !== JSON.stringify(next[key])) {
      delta[key] = next[key];
    }
  }
  return Object.keys(delta).length > 0 ? delta : null;
}

null が返ったときは送信をスキップします。受信側では Object.assign(state, delta) のように差分をマージするだけで済みます。

ネストの深いオブジェクトに対して浅い比較(===)だけを使うと差分の検出漏れが起きます。上の例では JSON.stringify で深い比較を代替していますが、これはネストが浅い場合に適した簡易実装です。ネストが2〜3段を超える場合は、後述の「フラット化」を先に検討するほうが安全です。

判断基準:どこまで差分にするか

差分送信を導入するかどうか、どの粒度で差分にするかは、ゲームの種類によって変わります。

ターン制ゲームの場合

チェスや将棋のようなターン制では、「操作内容だけを送る」アプローチが最もシンプルです。「プレイヤーAが(3,4)→(5,4)に駒を動かした」という操作ログだけを送り、受信側が同じロジックで状態を再現します。ゲームステート全体を送らないため、差分というより「コマンド送信」とも呼ばれます。送信量は一手あたり数十バイト程度に収まります。

リアルタイムアクションの場合

毎フレーム座標が変わるキャラクターが複数いる場合は、「座標・速度のみを送る帯域最適化」と「差分送信の組み合わせ」が現実的です。たとえば、HP・スコア・アイテム所持状況は変化したフレームだけ送り、座標と速度は一定間隔でまとめて送る(位置同期)という役割分担にすると、実装の複雑さを抑えながら通信量を減らせます。

判断の目安

  • 毎フレーム変わる(座標・速度)、推奨アプローチ:固定間隔の位置同期・補間
  • 数秒に一度変わる(HP・スコア)、推奨アプローチ:差分送信(変化時のみ)
  • イベント駆動(アイテム取得・死亡)、推奨アプローチ:イベント通知
  • ターンごとに変わる(盤面)、推奨アプローチ:コマンド送信

実装で失敗しやすいポイント

スナップショットのズレ:送信側と受信側で「前回の状態」が食い違うと、差分のマージが壊れます。これは「送信したが届かなかった(パケットロス)」「順序が入れ違った」ときに起きます。対策として、一定間隔(例:5秒ごと)に全状態を送るフルスナップショットを挟み、ズレをリセットする設計が安全です。差分送信と定期フルスナップショットの組み合わせは、多くのゲームサーバーで使われている基本的なパターンです。

ネストが深いオブジェクトの罠:players[0].inventory[2].count のようなパスがある場合、ルートオブジェクトの比較では変化を正しく検出できないことがあります。ゲームステートをできるだけフラット(階層1〜2段)に設計しておくと、差分検出のロジックが単純になり、バグの温床を減らせます。

差分が「ほぼ全部」になるケース:リアルタイムアクションで毎フレーム全キャラクターが動くと、差分が実質的にフルステートと変わりません。この場合は差分送信の効果が薄く、むしろオーバーヘッドだけが増えます。「変化率が高い状態には差分を使わない」という判断も重要です。

受信側のマージ忘れ:差分を受け取ったとき、state = delta と書いてしまうと前回の状態が消えます。必ず Object.assign(state, delta) や { ...state, ...delta } でマージしてください。これは単純なバグですが、気づきにくい不整合を生みます。

バイナリ化・圧縮との組み合わせ

差分を取り出した後、さらにMessagePackやCBORなどのバイナリシリアライゼーションで圧縮すると、JSONより転送量を削減できます。ブラウザでもMessagePackのライブラリは複数公開されています。ただし、デバッグ時にパケット内容が読めなくなるため、開発中はJSONで進め、最適化フェーズでバイナリに切り替えるほうが作業効率は上がります。

gzip圧縮をWebSocketレベルでかける(permessage-deflate拡張)方法もあります。サーバー側のライブラリが対応していれば設定一つで有効にでき、テキストデータには特に効果的です。ただしCPU負荷がかかるため、接続数が多い場合は計測しながら判断してください。

次の行動

まず手元のゲームステートオブジェクトを一度コンソールに出力し、「毎フレーム変わっているフィールド」と「ほとんど変わらないフィールド」を目視で分類してみてください。変化頻度が低いフィールドから差分送信を適用するだけで、通信量の削減効果を最も手軽に得られます。ターン制ゲームであれば、まずコマンド送信方式を試してみるのが最短の出発点です。完成したゲームはアソバースにZIPまたはHTMLで投稿し、実際のネットワーク環境で動作を確認してみてください。

参考資料

  • MDN Web Docs「WebSocket API」
  • RFC 6455(The WebSocket Protocol)
  • MessagePack 公式ドキュメント