🌍

ゲームサーバーなしで世界規模のマルチプレイヤー同期 — Cloudflare DO のスケーリング設計

に公開

この記事は、Claude Code のクロウ🐦 くんが Holonia プロジェクトのコードベースと ADR をもとに執筆し、開発者 @tyabu12 が内容をレビュー・監修したものです。コードサンプルはすべて実際のプロジェクトコードから引用しています。

加筆履歴: 2026/03/25 — DO のレイテンシ特性とクライアント補間に関する記載を追加

レビューで変わったところ(主な修正)
  • _broadcastProximity の空間ハッシュグリッド活用を追加(ブログ執筆中にバグ発見 → 実装修正)
  • D1 料金を Workers Paid / Free プラン別に正確に記述(公式ドキュメントと照合)
  • V8 Isolate 制約の記述を修正(nodejs_compat で fs/net 等が利用可能になっている点を反映)
  • D1 スケーリング戦略に DAU 換算・コスト感・シャードキー選定の詳細を追記
  • PartyServer (PartyKit) との技術選定比較を追加
  • DO のシングルロケーション・レイテンシ特性とクライアント補間の今後の課題を追記

この記事は、Holonia の開発で得た知見をシリーズでお届けする連載の第3回です。前回は Godot で「ゲームじゃないアプリ」を作る設計パターンについてお話ししました。

ゲームサーバーなしでリアルタイム同期?

バーチャルワールドを作っていると、避けて通れないのが「リアルタイム同期」です。アバターが歩いたら他のプレイヤーにも見えなきゃいけないし、チャットも即座に届いてほしいですよね。

普通に考えたら、専用のゲームサーバーを立てるところですよね。ECS や Kubernetes でゲームサーバーを運用する場合、こんな課題がつきまといます:

  • マルチリージョン展開が大変: 世界中のユーザーに低レイテンシで届けるには、東京・北米・欧州…とリージョンごとにサーバーを配置して、ルーティングやフェイルオーバーを自前で設計する必要があります
  • インフラ管理コストが爆発する: オートスケールの設定、デプロイパイプライン、監視、セキュリティパッチ — サーバーの「お守り」が本業を圧迫します
  • 最低1台は常時起動: ユーザーがゼロでもサーバーは動き続けます。PoC フェーズで月額数万円が飛んでいくのは地味に痛いですよね

Cloudflare Workers + Durable Objects なら、世界 300 以上のエッジロケーションに自動でデプロイされ、ユーザーに最も近い場所で処理が走ります。インフラ管理はゼロ、アイドル時のコストもゼロ。PoC にも本番にもフィットする、ちょうどいい選択肢でした。

私たちのプロジェクト Holonia では、バックエンドを全部 Cloudflare Workers で動かしています。Node.js ですらない、V8 Isolate の世界です。そこに「マルチプレイヤー同期」を乗せるとなると、選択肢は自然と Durable Objects (DO) に絞られました。

この記事では、Durable Objects + WebSocket でリアルタイム同期を実装した全体像と、実際にハマったポイントを共有します。

Holonia World のメイン画面(3D ワールド + HUD オーバーレイ)

Holonia のアーキテクチャ概要

まず前提として、Holonia はバーチャルワールドプラットフォームの実証実験(PoC)プロジェクトで、2 つのアプリで構成されています。多言語対応のデイリーユースアプリとして、世界中のどこから繋いでも耐えられるグローバルスケールの負荷設計を目指しています。

  • Holonia World: Godot Engine 4.6 で作った 3D バーチャルワールド(PC + モバイル)
  • Holonia Pocket: Flutter で作ったデイリーユースアプリ(チャット、ウォレット(予定)など)

両方とも同じバックエンドを共有していて、構成はこんな感じです:

Cloudflare Workers (Hono) --- API Gateway
Cloudflare D1             --- リレーショナル DB
Cloudflare R2             --- アセットストレージ
Cloudflare Durable Objects --- リアルタイム同期 (WebSocket)

リアルタイム部分を担当するのが Durable Objects です。ここからが本題ですね。

Durable Objects って何がうれしいの?

Durable Objects を一言でいうと「ステートフルなシングルスレッドアクター」です。
何がうれしいかというと:

  • シングルロケーション保証: 1 つの DO インスタンスは世界中のどこか 1 箇所でだけ動きます。ロックも排他制御もいりません
  • WebSocket Hibernation API: 接続中のソケットをハイバネート(休止)できます。誰も喋ってない間はコストゼロです
  • Workers との統合: Workers のルートから stub.fetch() で直接呼べます。EC2 とか別サーバーを立てる必要がありません

ゲームサーバーでいう「ルーム」の概念を、DO 1 インスタンス = 1 ルームとして自然にマッピングできるんです。

トレードオフ:シングルロケーションの裏側

ただし、「世界中のどこか 1 箇所でだけ動く」という特性には裏があります。DO インスタンスの配置先は最初の get() リクエスト元に近いデータセンターに決まり、一度配置されると自動では移動しません[1]locationHint で初期配置のヒントを指定できますが、保証ではありません。

つまり、DO がヨーロッパに配置されて東京からアクセスすると、往復 200〜300ms のレイテンシが発生しうるんです。これは光速の物理的制約であり、DO の設計上のトレードオフです。分散 DB が結果整合性というコストを払うのと同じで、ステートを 1 箇所に集約するアーキテクチャが払う代償ですね。

Holonia のようなグローバルサービスでは、後述するスケーリング戦略の Phase 3(リージョンシャーディング)で request.cf.continent による地理ルーティングを導入し、ユーザーに近い DO インスタンスへ振り分けることでこの問題を緩和する計画です。

ちなみに、この問題は wrangler dev でのローカル開発では気づきにくいです。ローカルだとレイテンシがほぼゼロなので、本番デプロイ後に海外ユーザーから「重い」と言われて初めて顕在化するパターンもあります。

なぜ PartyKit (PartyServer) を使わなかったのか?

Durable Objects の上に薄い抽象化を被せた PartyServer というフレームワークもあります。onConnect / onMessage のライフサイクルフック、broadcast() 一発のブロードキャスト、自動再接続クライアント (partysocket) など、チャットルームのようなシンプルなリアルタイム機能なら手軽に始められます。

私たちが素の Durable Objects を選んだのは、バーチャルワールド特有の要件があったからです:

  • 空間フィルタリング (AOI — Area of Interest、関心領域): プレイヤーの位置に応じて送信先を絞り込む。PartyServer の broadcast() は全接続に送るだけなので、結局自分でフィルタリングを書くことになります
  • サーバーサイドバッチング: 20Hz で更新をまとめて送る。PartyServer には組み込みのバッチング機能がありません
  • コンパクトなプロトコル: per-recipient でペイロードを最適化したい。汎用の broadcast では対応できません
  • クライアントが JS じゃない: partysocket は JavaScript/TypeScript 専用ですが、私たちのクライアントは Godot (GDScript)、Flutter (Dart)、Go です

チャットルームのような「全員に同じメッセージを配信」するユースケースなら PartyServer は良い選択肢です。ただ、空間的なフィルタリングが必要なリアルタイムアプリでは、DO のローレベル API を直接使う方がフィットしました。

2 つの DO に分けた理由

リアルタイム同期には 2 種類のトラフィックがあります:

種類 特徴 頻度
アバター位置同期 高頻度、エフェメラル(一時的)、永続化不要 20Hz
チャットメッセージ 低頻度、永続化が必要(D1 に保存) ユーザー操作時のみ
クイックチャット 中頻度、近接範囲配信(50 units)、永続化不要 ユーザー操作時

位置同期の高頻度トラフィックとチャットの永続化処理を 1 つの DO に詰め込むと、お互いに影響し合ってしまいます。なので DO クラスを 2 つに分けました。

クイックチャット(短いメッセージ)は永続化不要で、しかも送信者の近く(50 units 以内)にいるプレイヤーにだけ届けたいので、位置情報を持っている WorldPresenceDO 側で処理しています。

ChatRoomDO        --- 1 インスタンス / チャットルーム
WorldPresenceDO   --- 1 インスタンス / (シーン x セル x リージョン)

この分離により、それぞれ独立にスケールできますし、コードもシンプルになります。

WebSocket ルートの実装

WebSocket のアップグレードリクエストは JSON ボディを持てないので、OpenAPI ルーティングとは別に普通の Hono ルートで処理しています。

Godot (World) 側からは GET /api/ws/world/:scene_id、Flutter (Pocket) 側からは GET /api/ws/chat/:room_id で接続します。

backend/src/routes/ws.ts
wsRoute.get("/world/:scene_id", async (c) => {
  if (c.req.header("Upgrade") !== "websocket") {
    return c.text("Expected WebSocket upgrade", 426);
  }

  const sceneId = c.req.param("scene_id");
  const userId = c.req.query("user_id") ?? "anonymous";

  // Server-side lookup — never trust client-provided display_name/handle.
  let displayName = "";
  let handle = "";
  let avatarConfig = "";
  try {
    const user = await getUserById(createDb(c.env.DB), userId);
    displayName = user?.display_name ?? "";
    handle = user?.handle ?? "";
    avatarConfig = user?.avatar_config
      ? JSON.stringify(user.avatar_config)
      : "";
  } catch {
    // Invalid UUID format or DB error — fall through with empty names
  }

  // Phase 1: cell is always 0:0, region is "default"
  const cellX = Number.parseInt(c.req.query("cell_x") ?? "0", 10) || 0;
  const cellZ = Number.parseInt(c.req.query("cell_z") ?? "0", 10) || 0;
  const region = "default";

  // DO ID = "scene_id:cell_x:cell_z:region" → 1 DO = 1 空間セル
  const doName = derivePresenceDoId(sceneId, cellX, cellZ, region);
  const doId = c.env.WORLD_PRESENCE_DO.idFromName(doName);
  const stub = c.env.WORLD_PRESENCE_DO.get(doId);

  const url = new URL(c.req.url);
  url.searchParams.set("user_id", userId);
  url.searchParams.set("display_name", displayName);
  url.searchParams.set("handle", handle);
  url.searchParams.set("avatar_config", avatarConfig);

  return stub.fetch(new Request(url.toString(), c.req.raw));
});

ここのポイントは 2 つあります:

  • DO ID の命名規則が最初からスケール対応になっています
    • Phase 1 では scene_id:0:0:default の 1 セルだけですが、将来セル分割が必要になってもプロトコル変更なしで対応できます

プロトコル定義: Zod で型安全に

WebSocket のメッセージプロトコルは Zod スキーマで定義しています。TypeScript の型推論もランタイムバリデーションもこれ一発で済みます。

backend/src/durable-objects/protocol.ts
// Chat frames
export const chatSendSchema = z.object({
  type: z.literal("chat:send"),
  content: z.string().min(1).max(4096),
  user_id: z.string(),
});

export const chatNewMessageSchema = z.object({
  type: z.literal("chat:new_message"),
  id: z.string(),
  room_id: z.string(),
  user_id: z.string(),
  display_name: z.string(),
  content: z.string(),
  created_at: z.string(),
});

// Presence frames
const positionSchema = z.tuple([z.number(), z.number(), z.number()]);

export const presenceUpdateSchema = z.object({
  type: z.literal("presence:update"),
  user_id: z.string(),
  position: positionSchema,
  rotation_y: z.number(),
  cell_x: z.number().int().optional().default(0),
  cell_z: z.number().int().optional().default(0),
  region: z.string().optional().default("default"),
});

// Batched movement updates — compact array format
export const presenceBatchSchema = z.object({
  type: z.literal("presence:batch"),
  /** [[user_id, x, y, z, rotation_y], ...] */
  m: z.array(
    z.tuple([z.string(), z.number(), z.number(), z.number(), z.number()]),
  ),
});

// Quick Chat frames (proximity, via WorldPresenceDO)
export const quickChatSendSchema = z.object({
  type: z.literal("quick_chat:send"),
  content: z.string().min(1).max(280),
});

export const quickChatMessageSchema = z.object({
  type: z.literal("quick_chat:message"),
  user_id: z.string(),
  content: z.string(),
  position: positionSchema, // sender's position for bubble placement
});

export const quickChatTypingSchema = z.object({
  type: z.literal("quick_chat:typing"),
  is_typing: z.boolean(),
});

export const quickChatTypingIndicatorSchema = z.object({
  type: z.literal("quick_chat:typing_indicator"),
  user_id: z.string(),
  is_typing: z.boolean(),
});

位置同期と同じ WorldPresenceDO で処理されるクイックチャットのフレームも、同じプロトコルファイルで定義しています。クライアントは quick_chat:send で短いメッセージ(最大 280 文字)を送信し、サーバーは近接範囲内のプレイヤーに quick_chat:message として配信します。position フィールドが付いているのは、受信側で 3D 空間上の吹き出し位置を決めるためです。

タイピングインジケーターも同様に quick_chat:typing(クライアント→サーバー)と quick_chat:typing_indicator(サーバー→クライアント)のペアで管理しています。is_typing: false はサーバー側でも 5 秒間操作がなければ自動送信されるので、クライアントが切断しても「...」が残り続けることはありません。

Zod の discriminatedUnion を使えば、type フィールドの値で自動的に型が絞り込まれます。switch (frame.type) で分岐したときに TypeScript が未処理のケースをコンパイルエラーにしてくれる(Exhaustive Check)ので、新しいフレームタイプを追加したときに処理漏れを防げます:

backend/src/durable-objects/protocol.ts
export const clientToPresenceSchema = z.discriminatedUnion("type", [
  presenceUpdateSchema,
  quickChatSendSchema,
  quickChatTypingSchema,
]);

パースのヘルパーもシンプルです:

backend/src/durable-objects/protocol.ts
export function parsePresenceFrame(
  raw: string,
): PresenceUpdate | QuickChatSend | QuickChatTyping | null {
  try {
    const parsed = JSON.parse(raw);
    const result = clientToPresenceSchema.safeParse(parsed);
    return result.success ? result.data : null;
  } catch {
    return null;
  }
}

不正なフレームは null を返して静かに捨てます。WebSocket はインターネット越しに誰でも繋げるので、「おかしなデータが来ても落ちない」ように防御的に書くのが大事です。

ChatRoomDO: チャットの永続化とブロードキャスト

ChatRoomDO はわりとシンプルです。
やることは「メッセージを D1 に保存して、接続中の全員にブロードキャスト」。
これだけなんです。

backend/src/durable-objects/chat-room-do.ts
export class ChatRoomDO extends DurableObject<Bindings> {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname.endsWith("/internal/notify")) {
      return this._handleInternalNotify(request);
    }

    // WebSocket upgrade
    if (request.headers.get("Upgrade") !== "websocket") {
      return new Response("Expected WebSocket upgrade", { status: 426 });
    }

    const userId = url.searchParams.get("user_id") ?? "anonymous";
    const pathParts = url.pathname.split("/");
    const roomId = pathParts[pathParts.length - 1] ?? "";

    const { 0: client, 1: server } = new WebSocketPair();
    this.ctx.acceptWebSocket(server, [userId, roomId]);
    return new Response(null, { status: 101, webSocket: client });
  }

  // ...
}

ちょっと面白いのが 2 つの送信パス があるところです:

  1. WebSocket パス: クライアントが chat:send フレームを送る → DO が D1 に保存 → ブロードキャスト
  2. REST パス: POST /rooms/:id/messages → Worker が D1 に保存 → DO の /internal/notify を呼んでブロードキャスト

つまり REST API 経由で送ったメッセージも、WebSocket 接続中の全クライアントにリアルタイムで届きます。Pocket (Flutter) から REST で送ったチャットが World (Godot) 側に即座にプッシュされる、というわけですね。

D1 への書き込みは db.batch() で 1 回のラウンドトリップにまとめています:

backend/src/durable-objects/chat-room-do.ts
private async _persistAndBroadcast(
  roomId: string,
  userId: string,
  content: string,
): Promise<void> {
  const db = createDb(this.env.DB);
  const id = generateUUIDv7();
  const preview = content.slice(0, 100);
  const now = sql`strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;

  // Single D1 roundtrip: SELECT display_name + INSERT message + UPDATE room
  const results = await db.batch([
    db.select({ display_name: users.display_name })
      .from(users)
      .where(eq(users.id, userId)),
    db.insert(chatMessages)
      .values({ id, room_id: roomId, user_id: userId, content })
      .returning(),
    db.update(chatRooms)
      .set({ last_message_at: now, last_message_preview: preview })
      .where(eq(chatRooms.id, roomId)),
  ]);

  // ...broadcast to all connected clients
}

WorldPresenceDO: アバター位置同期の本丸

こちらが本丸です。
WorldPresenceDO はメモリ上に全プレイヤーの位置・回転を保持して、高頻度で更新をブロードキャストします。D1 には書きません。プレゼンス(存在情報)は一時的なものなので、DO が消えたらそれまでです。

バッチフラッシュ (20Hz)

素朴にやると、プレイヤー A が動くたびに全員に presence:moved を個別送信することになります。プレイヤーが 100 人いたら 1 回の移動で 100 メッセージ。これはさすがにきついですよね。

そこで サーバーサイドバッチング を導入しました。50ms (20Hz) ごとに、その間に動いたプレイヤーの位置をまとめて 1 つの presence:batch フレームとして送ります:

/** Batched movement updates — compact array format */
const batchFrame: PresenceBatch = {
  type: "presence:batch",
  m: entries, // [[user_id, x, y, z, rotation_y], ...]
};

presence:batch のペイロードはコンパクトなタプル配列です。キー名を省略することで JSON でもそれなりに小さくなります。

Area of Interest (AOI) フィルタリング

もう一つ重要なのが AOI (Area of Interest) です。
プレイヤーから 100 ユニット以上離れた場所の動きは、画面に映らないので送る必要がありません。

backend/src/durable-objects/world-presence-do.ts
/** Area-of-Interest radius — only send movement updates within this XZ range. */
const AOI_RADIUS = 100;

AOI の判定には 空間ハッシュグリッド を使っています。
全プレイヤーとの距離を毎回計算すると O(N^2) で重くなりますが、グリッドに分けておけば自分の周囲 3x3 セルだけチェックすれば大丈夫です:

backend/src/durable-objects/world-presence-do.ts
// Optimized path: only check moved players in the 3×3 neighborhood
const candidates = new Set<string>();
const cx = Math.floor(recipientState.position[0] / GRID_CELL_SIZE);
const cz = Math.floor(recipientState.position[2] / GRID_CELL_SIZE);
for (let dx = -1; dx <= 1; dx++) {
  for (let dz = -1; dz <= 1; dz++) {
    const cellSet = this._grid.get(`${cx + dx},${cz + dz}`);
    if (cellSet) {
      for (const uid of cellSet) {
        if (this._movedUserIds.has(uid)) {
          candidates.add(uid);
        }
      }
    }
  }
}

AOI に入った瞬間は presence:join、出たら presence:leave を送ります。プレイヤーの「見える範囲」をサーバー側で管理することで、帯域を大幅に節約できます。ワールドに 200 人いても、自分の周りの 20 人分の更新しか飛んできません。

                            AOI (100 units)
                  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
                  │                               │
                  │    🧑 ← 同期される              │
                  │       ┌ ─ ─ ─ ─ ─ ─ ─ ─ ┐     │
                  │       │   Proximity     │     │
                  │       │   (50 units)    │     │
                  │       │     ★ 自分       │     │
                  │       │🧑‍🦰 ← チャットも届く │     │
                  │       │                 │     │
                  │       └ ─ ─ ─ ─ ─ ─ ─ ─ ┘     │
                  │              🧑‍🦱               │
                  │   ← 位置は同期、チャットは届かない │
                  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
                                               🧑 ← 同期されない(見えない)

クイックチャット: 近接範囲の短いメッセージ

WorldPresenceDO はアバター位置だけでなく、クイックチャット(短いメッセージ)も担当しています。通常のチャット(ChatRoomDO)と違って、クイックチャットは永続化しません。3D 空間上で近くにいるプレイヤーだけに届く、いわばゲームでよく見る「エモート」のような機能です。

なぜ WorldPresenceDO で処理するのか?答えはシンプルで、近接判定に位置情報が必要だからです。AOI フィルタリング(100 units)よりさらに狭い 50 units の範囲で配信します:

backend/src/durable-objects/world-presence-do.ts
/** XZ distance threshold for proximity quick chat delivery. */
const PROXIMITY_RADIUS = 50;

配信ロジックは _broadcastProximity メソッドです。AOI のバッチフラッシュと同じ空間ハッシュグリッドを使って、まず 3×3 近傍セルから候補を絞り込み、その中で距離計算をします:

backend/src/durable-objects/world-presence-do.ts
private _broadcastProximity(
  senderWs: WebSocket,
  senderPos: [number, number, number],
  frame: object,
): void {
  // Build candidate set from spatial grid (3×3 neighbourhood)
  const candidates = new Set<string>();
  const cx = Math.floor(senderPos[0] / GRID_CELL_SIZE);
  const cz = Math.floor(senderPos[2] / GRID_CELL_SIZE);
  for (let dx = -1; dx <= 1; dx++) {
    for (let dz = -1; dz <= 1; dz++) {
      const cellSet = this._grid.get(`${cx + dx},${cz + dz}`);
      if (cellSet) {
        for (const uid of cellSet) {
          candidates.add(uid);
        }
      }
    }
  }

  const payload = JSON.stringify(frame);
  for (const ws of this.ctx.getWebSockets()) {
    if (ws === senderWs) continue;
    const tags = this.ctx.getTags(ws);
    const uid = tags[0] || "";
    if (!candidates.has(uid)) continue;
    const player = this._players.get(uid);
    if (!player) continue;
    const pdx = player.position[0] - senderPos[0];
    const pdz = player.position[2] - senderPos[2];
    const distXZ = Math.sqrt(pdx * pdx + pdz * pdz);
    if (distXZ > PROXIMITY_RADIUS) continue;
    try {
      ws.send(payload);
    } catch {
      // ignore
    }
  }
}

AOI フィルタリングと同じグリッドを再利用しているので、追加のデータ構造は不要です。

余談: ブログを書いたらバグが見つかった

実はこのグリッド活用は、この記事のレビュー中に「AOI はグリッド使ってるのにクイックチャットは全走査してるのでは?」と気づいて改善したものです。ブログを書くことがコードレビューにもなる、という予想外の副産物でした。

タイピングインジケーター(「...」バブルの表示)も同じ近接範囲で配信され、5 秒間操作がなければ自動的にクリアされます。遠くのプレイヤーの「...」が見えても意味がないですからね。

Godot 側: WebSocketManager

Godot (World) 側では、WebSocketManager という Autoload(シングルトン)が 2 本の WebSocket を管理しています[2]

world/scripts/autoload/websocket_manager.gd
extends Node

# --- シグナル: 上位ノード (RemotePlayerManager 等) がこれらを購読してシーンを更新する ---
signal players_snapshot(players: Array)          # 接続時に全プレイヤーの初期状態を受信
signal player_joined(user_id: String, display_name: String, position: Vector3, rotation_y: float, avatar_config: Dictionary)
signal player_moved(user_id: String, position: Vector3, rotation_y: float)
signal player_left(user_id: String)
signal presence_disconnected                     # 再接続が必要なときに発火
signal quick_chat_received(user_id: String, content: String, position: Vector3)
signal typing_indicator_received(user_id: String, is_typing: bool)

var _presence_ws := WebSocketPeer.new()  # アバター位置同期 + クイックチャット用
var _chat_ws := WebSocketPeer.new()      # チャットルーム用(別 DO に接続)

クイックチャットもプレゼンスと同じ WebSocket で受信します。quick_chat:message が来たらシグナルに変換し、position を一緒に渡すことで、上位のシーンが 3D 空間上に吹き出しを配置できるようになっています:

world/scripts/autoload/websocket_manager.gd
"quick_chat:message":
    var uid: String = msg.get("user_id", "")
    var content: String = msg.get("content", "")
    var pos := parse_vec3(msg.get("position", [0.0, 0.0, 0.0]))
    quick_chat_received.emit(uid, content, pos)
"quick_chat:typing_indicator":
    var uid: String = msg.get("user_id", "")
    var typing: bool = msg.get("is_typing", false)
    typing_indicator_received.emit(uid, typing)

送信側もシンプルです。JSON フレームを組み立てて送るだけ:

world/scripts/autoload/websocket_manager.gd
func send_quick_chat(content: String) -> void:
    if _presence_ws.get_ready_state() != WebSocketPeer.STATE_OPEN:
        return
    var frame := JSON.stringify({"type": "quick_chat:send", "content": content})
    _presence_ws.send_text(frame)

ポイントは Timer ベースのポーリング を使っていることです。Godot の _process() を使うとフレームレート依存になりますし、Comfort モード(省電力モード)でポーリング頻度を柔軟に変えたいので:

world/scripts/autoload/websocket_manager.gd
const _POLL_INTERVAL_SEC := 0.05  ## 20Hz polling
const _POLL_INTERVAL_BG_SEC := 1.0  ## 1Hz background polling

@onready var _poll_timer: Timer = Timer.new()

func _ready() -> void:
    add_child(_poll_timer)
    _poll_timer.wait_time = _POLL_INTERVAL_SEC
    _poll_timer.autostart = true
    _poll_timer.timeout.connect(_tick)
    _poll_timer.start()
    ComfortMode.backgrounded_changed.connect(_on_backgrounded_changed)

バックグラウンドに入ったら 1Hz に落とします。Holonia World はゲームというよりデイリーユースアプリなので、開きっぱなしでもバッテリーを食い潰さないのが大事ですね。

再接続は 指数バックオフ (1s → 2s → 4s → ... 最大 30s) です:

world/scripts/autoload/websocket_manager.gd
func _schedule_presence_reconnect() -> void:
    if _presence_url.is_empty():
        return
    _presence_reconnecting = true
    _presence_reconnect_timer = _presence_reconnect_delay
    _presence_reconnect_delay = minf(_presence_reconnect_delay * 2.0, _BACKOFF_MAX_SEC)

受信側では presence:batch をパースして、各プレイヤーの player_moved シグナルに変換します:

world/scripts/autoload/websocket_manager.gd
"presence:batch":
    var moves: Array = msg.get("m", [])  # m = [[uid, x, y, z, rot_y], ...]
    for entry: Array in moves:
        if entry.size() < 5:
            continue
        var uid: String = str(entry[0])
        var pos := Vector3(float(entry[1]), float(entry[2]), float(entry[3]))
        var rot_y: float = float(entry[4])
        player_moved.emit(uid, pos, rot_y)  # 上位ノードがアバターを移動させる

Godot のベストプラクティスに「Call down, signal up(親は子を直接呼ぶ、子は signal で通知する)」というパターンがあります。これに従って、WebSocketManager はシグナルを emit するだけです。実際にアバターをシーン上で動かす処理は上位の RemotePlayerManager が担当します。関心の分離ですね。

スケーリング戦略: Phase 1 から仕込む

Holonia はデイリーユースアプリとして多言語対応を進めています。つまり、設計の前提は「世界中の人がアクセスしても耐えられること」です。PoC とはいえ、スケーリングの天井が低い設計をしてしまうと後から変えるのが大変なので、Phase 1 の時点からグローバルスケールを意識した ID 設計をしています。

WorldPresenceDO の ID フォーマットがその鍵です:

DO ID = "{scene_id}:{cell_x}:{cell_z}:{region}"
Phase 1: "main_plaza:0:0:default"

Phase 1 ではシーン全体が 1 つの DO に入るのでシンプルです。でも将来的には:

  • 空間セル分割: ワールド座標からセルを割り出し、プレイヤーは移動先のセルの DO に接続
  • リージョンシャーディング: request.cf.continent で地理的に最寄りの DO にルーティング
  • セル容量超過時の四分木分割: 1 セルに 1,000 人超えたら自動で分割

これらは DO ID の命名規則を変えずに実現できます。プロトコルの breaking change なしにスケールできるのは大きいですよね。最初から「スケールしたとき困らない名前」をつけておくの、地味ですけど大事です。

各フェーズで理論上どこまで耐えられるか、試算してみました:

フェーズ 仕組み 同接上限/シーン DAU 換算*
Phase 1(現状) 1 シーン = 1 DO 1,000 〜1 万
Phase 2(セル分割) 空間セルごとに DO を分離 数十万〜100 万 〜1,000 万
Phase 3(リージョン) 大陸ごとに独立 DO セット 数百万〜1,000 万 〜1 億
フルシャーディング セル × リージョン × 複数シーン 数千万〜 数億〜

*DAU 換算はピーク同接率 10〜20% で逆算した概算値です。Phase 1 の 1,000 はソフトリミットで、超過しても接続は拒否されません(警告ログのみ)。

もちろん、フルシャーディングに到達するころにはユーザー DB 層(D1 のシングルライター制約)など別のボトルネックが出てくるので、ここから先はインフラ全体の再設計が必要です。でも、リアルタイム同期層だけは Phase 1 のコードを変えずにスケールできる — これが DO ID 設計の狙いです。

D1 の壁: リアルタイム層と永続化層の非対称性

上の表はリアルタイム同期層(DO)のスケーリングですが、データ永続化層(D1)には別の天井があります。

D1 の中身は SQLite で、「1 ライター + N リーダー」のアーキテクチャです。読み取りはリードレプリカで地理分散できますが、書き込みは常に 1 箇所のプライマリでシリアル実行されます。

DAU が増えてくると、この書き込みキューがボトルネックになります。対策はいくつかの段階に分かれます:

段階 手法 DAU 目安 コスト感
読み書き分離 D1 リードレプリカで読み取りを分散 〜10 万 追加コストなし(D1 標準機能)
論理シャーディング チャット用・ユーザー用・ウォレット用で D1 を分離 〜100 万 D1 インスタンスごとに 5GB 無料枠、超過分 $0.75/GB-mo
物理シャーディング ドメイン別にシャードキーを選定(チャットは room_id、ウォレットは wallet_id 〜数千万 256 シャード × ストレージ + 書き込み $1.00/百万行(無料枠後)
分散 DB 移行 PlanetScale / Turso / CockroachDB 等 1 億超 月額 $29〜数百ドル(サービスにより大幅に異なる)

例えば物理シャーディング(256 シャード)なら、D1 1 インスタンスの書き込みスループット(~1,000〜10,000 writes/sec)× 256 で、DAU 数百万〜数千万は現実的な範囲です。シャードキーはドメインごとに最適なものを選びます — チャットなら room_id(同じルームのデータが同一シャードに収まる)、ウォレットなら wallet_id です。user_id で分割すると、チャットルーム参加者のプロフィール取得がクロスシャードになり非効率なので避けています。ただし実際のキャパはチャット頻度やウォレット操作量に大きく依存するので、ベンチマークによる検証が必須です。

リアルタイム同期層が DO のおかげでコード変更なしにスケールできるのに対して、データ永続化層はフェーズごとに手を入れる必要がある — この非対称性を意識しておくのが大事です。

参考までに、D1 の料金体系はかなり良心的です。Workers Paid プランでは読み取りは月 250 億行まで無料(超過 $0.001/百万行)、書き込みは月 5,000 万行まで無料(超過 $1.00/百万行)、データ転送料なし。無料プランでも 1 日あたり読み取り 500 万行・書き込み 10 万行が使えるので、PoC にはぴったりです。

リアルタイム同期層(DO の料金)側も押さえておきましょう。DO のリクエスト課金は $0.15/百万リクエストで、WebSocket メッセージは 20:1 の比率が適用されます(100 メッセージ = 5 リクエスト扱い)。さらに Hibernation API のおかげで、誰も喋っていない間はアイドル課金もゼロ。スケーラビリティとコスト効率を両立できるのが Cloudflare の強みですね。

※ 料金は 2026 年 3 月時点の情報です。最新の料金は上記リンク先をご確認ください。

D1 のスケーリング戦略だけで記事が 1 本書けそうなボリュームなので、ここでは概要にとどめます。シャードキーの選定やキャッシュ層の設計など、詳しい話はいずれ別の記事でご紹介できればと思います。

80 億人全員が同時接続する世界はまだ先ですが、そこに向かう道筋が最初から設計に織り込まれているのは、悪くない出発点だと思っています。

ハマったところ・学んだこと

V8 Isolate の制約

Cloudflare Workers は V8 Isolate で動きます。Node.js ではありません。TypeScript/JavaScript が動くからといって Node.js のつもりで書くとハマります:

  • グローバル変数は Workers のリクエスト間で共有されません(DO はステートフルなので別です)
  • ローカルファイルシステムへの実際の読み書きはできません(サンドボックス環境です)
  • child_process のプロセス起動など、OS レベルの操作は機能しません

ただし、Workers の Node.js 互換性は急速に改善していますnodejs_compat フラグを有効にすると、fsnetcryptoBuffer など多くの Node.js API が利用できます。例えば UUIDv7 の生成には uuid パッケージ(v13+)をそのまま使えています。RFC 9562 準拠の安全な UUID 生成を、自前実装なしで利用できるのはありがたいですね。

とはいえ、すべての npm パッケージが動くわけではありません。確認する一番確実な方法は wrangler dev で実際に動かしてみることです。公式の Node.js 互換性ドキュメントにサポート済み API の一覧もあるので、事前に目を通しておくと安心です。

Hibernation API の挙動

Hibernation API は便利ですが、いくつか注意点があります:

  • webSocketClose / webSocketError でコネクション追跡は不要です。Hibernation API が自動管理してくれます
  • ctx.getWebSockets() で現在の接続一覧が取れます。タグ (ctx.getTags(ws)) で user_id などのメタデータを紐付けておきます
  • DO がハイバネートしてからのウェイクアップは数十 ms かかります。Isolate 自体のコールドスタートとは異なり、メモリ状態を保持したままの復帰なので十分に高速ですが、初回メッセージだけ若干遅延する可能性があります

JSON ペイロードの最適化

現時点では JSON プロトコルのままですが、いくつかの工夫で帯域を節約しています:

  • presence:batch はキー名を m の 1 文字にして、値もタプル配列にしました
  • 浮動小数点は小数点以下 2 桁に丸めて桁数を減らします (round2)
  • AOI フィルタリングで「そもそも送らない」のが最大の帯域節約です
backend/src/durable-objects/world-presence-do.ts
function round2(n: number): number {
  return Math.round(n * 100) / 100;
}

将来的には Protocol Buffers への移行も視野に入れています。presence:batch(10 プレイヤー分)で JSON ~450 bytes → Protobuf ~130 bytes と約 70% の帯域削減が見込めますし、.proto ファイルからの型コード生成で TypeScript / Go / GDScript / Dart 間の手動ミラーリングもなくなります。ただし YAGNI の精神で、JSON で十分動いてるうちは移行しない方針です。

WebSocket 接続の信頼性

インターネット越しの WebSocket は切れます。絶対に切れます。電車でトンネル入ったら切れますし、WiFi が切り替わっても切れます。だからクライアント側の再接続ロジックは必須です:

  • 指数バックオフで再接続試行
  • 接続開始時にサーバーから presence:snapshot を送信して、途中参加でも現在の状態を取得できます
  • タイピングインジケーターには自動期限切れタイマー (5 秒) を設定しています。切断時にクリーンアップされなくても、タイマーで消えます

まとめと今後

Durable Objects でマルチプレイヤー同期を作ってみて、率直な感想は「これ、いいですね」です。

良かった点:

  • インフラ管理ゼロです。EC2 も GKE も Kubernetes もありません。DO のインスタンスは Cloudflare が勝手にスケールしてくれます
  • Hibernation API のおかげでアイドル時のコストが実質ゼロです
  • Workers + D1 + DO + R2 が同一プラットフォームで、連携がスムーズです

課題:

  • デバッグがちょっと難しいです。ローカル開発時の wrangler dev は DO をサポートしてくれますが、本番での挙動が微妙に違うことがあります
  • レイテンシの可視化が難しい: 前述の通り、ローカル開発ではレイテンシ問題が再現できません。リージョンを意識したテスト環境の整備が今後の課題です
  • V8 Isolate の制約で使えないパッケージがあります
  • DO のリクエストあたり wall-clock 制限(30 秒)に収まるようにバッチ処理を設計する必要があります(Workers の Cron Triggers は別途 15 分の制限)

今後の展望:

  • クライアントサイド補間: 現状はサーバーからの presence:batch 更新ごとにアバター位置を直接セットしているため、更新間でアバターがワープします。position.lerp(target_position, delta * speed) による線形補間が最小限の改善策で、将来的には速度ベクトルも送信して予測補間(extrapolation)を行うことで、DO のレイテンシ特性と相まって体感を大きく改善できるはずです
  • Protocol Buffers への移行(帯域がボトルネックになったタイミングで)
  • 空間セル分割による水平スケーリング
  • Passkeys / WebAuthn による認証(現在は user_id をクエリパラメータで渡す仮実装)
  • Go 製のボットクライアントによる負荷テストで、スケーリング戦略を検証

専用ゲームサーバーを立てずにリアルタイム同期ができる時代、なかなか面白いですよね。Cloudflare の「エッジで全部やる」思想は、こういうユースケースにすごく合ってると思います。

同じようなことを考えてる方の参考になればうれしいです。


次回は少し趣向を変えて、AI × Discord で開発フローがどう変わったかという体験記をお届けします。コードの話からワークフローの話へ — お楽しみに。

脚注
  1. Cloudflare 公式ドキュメント Durable Objects — Data Location より。「a Durable Object is instantiated in a data center close to where the initial get() request is made」「Durable Objects do not currently change locations after they are created」と明記されています。動的な再配置は将来対応予定とのことです。 ↩︎

  2. Autoload は Godot でアプリ起動時に自動で読み込まれるグローバルなノードのことです。シーンが切り替わっても破棄されず、どこからでもアクセスできます。 ↩︎

Discussion