Work

2026

MMORPGサーバー

Gateway / World 分離の自作 MMORPG サーバー。KCP + protobuf、Redis、MongoDB。Unity クライアント通信と設計メモ。

サーバー / クライアント通信

制作期間 — 制作中

  • C#
  • .NET
  • Unity
  • KCP
  • Protobuf
  • Redis
  • MongoDB
  • Docker

全体構成

Unity クライアントは Gateway と World に別接続。永続化は MongoDB、セッションと入場調整は Redis が担う。

Unity Client

KCP クライアント / protobuf シリアライズ

KCP × 2 接続

Gateway

C# / .NET

World

C# / .NET(インスタンス単位)

Redis

セッション・入場調整

MongoDB

キャラ・インベントリ

Design

サーバー設計

自作 MMORPG のサーバー設計メモ。クライアントは操作の意図だけを送り、結果は Gateway / World が確定する。KCP + Protocol Buffers、Redis、MongoDB で構成。

01 / Architecture

サーバー構成

認証・マッチングとゲーム進行を分離した二層構成。参考にしたのは『ブループロトコル:スターレゾナンス』(中国語名:星痕共鳴)の Gateway / World 分離。実装・プロトコルはすべて自作。

Gateway / World の責務分担

ロビー系は Gateway、フィールド進行は World。クライアントは常に 2 本の KCP 接続を持つ(入場前は Gateway のみ)。

Gateway

登録・ログイン → session_token

パーティ作成・招待・ダンジョン入場投票

World インスタンス選定

world_token 発行(短命・単発消費)

World 接続先を通知

World

world_token 検証 → プレイヤー生成

戦闘・移動・ドロップ(コマンドキューで直列化)

HP・位置・敵状態はメモリが正

変更は非同期で MongoDB へ

session_token を World に渡さない。入場は必ず Gateway 経由の world_token のみ。

Gateway が担当する層

  • アカウント登録・ログイン。session_token を Redis に保持
  • マップ選択と World インスタンスの選定
  • パーティ作成・招待・ダンジョン入場投票(opcode 0x10 帯)
  • 短命な world_token を発行(入場 0x30 帯)。World へ session は渡さない

World が担当する層

  • world_token を検証してからプレイヤーをスポーン
  • 戦闘・移動・ドロップを WorldCommandQueue で直列化
  • ルーム内の HP・座標・敵・ドロップはメモリ上が唯一の正
  • MongoDB への書き込みは非同期。完了時に接続所有者を確認

なぜ分けるか

  • ロビー処理とフィールド処理をスケール単位で切り離せる
  • World を増やしても Gateway の認証フローはそのまま
  • 入場権限を Gateway に集約し、World は「検証済みトークンだけ」受け付ける
  • Redis でセッションと入場調整、MongoDB でキャラデータと分離

02 / Entry

入場フロー

フィールドに入るまでの状態遷移。パーティダンジョンは Gateway 上で投票が終わってから、全員が同じ World へ world_token で入る。

入場までの流れ

ソロでもパーティでも、フィールドに入る前の調整は Gateway で完結する。World はトークン検証後にだけ関与する。

1

ログイン

Gateway で認証 → session_token

2

マップ選択

Gateway が World インスタンスを決定

3

パーティ(任意)

Gateway で投票 → 全員同意

4

入場

world_token 発行 → World へ接続

5

プレイ

World で状態同期・戦闘・永続化

ソロ入場

  • LoginReq → session_token
  • MapSelectReq → Gateway が World を決定
  • EnterWorldReq → world_token 発行
  • クライアントが World へ KCP 接続 → スポーン

パーティ入場

  • PartyCreate / Invite / Join は Gateway(0x10 帯)
  • DungeonEnterVoteReq で全員の同意を集める
  • リーダーが EnterWorldReq → 各員に world_token
  • 全員が同じ World インスタンスへ接続

トークンの役割

  • session_token — ログイン後の長命セッション。Gateway 接続の維持に使う
  • world_token — 入場専用の短命トークン。Redis 上で単発消費(使い回し不可)
  • World は session_token を知らない。入場は world_token のみで判断する
  • トークンキャプチャされても、有効期限内の 1 回入場が限界(Threat Model で扱う)

03 / Protocol

プロトコル設計

KCP 上に opcode + protobuf の薄いフレーミング。ワイヤ形式の考え方は Grasscutter(原神 OSS サーバー)を参考にし、メッセージ定義は gateway.proto / world.proto として自作。

1 パケットの形

KCP の上に 1 バイト opcode と protobuf payload。Gateway / World で同じ GamePacketCodec を使うが、opcode 空間は接続ごとに独立。

opcode
protobuf payload

Req

Client → Server

意図だけ(対象 ID など)

Rsp

Server → Client

その Req への応答

Notify

Server → Client

サーバー主導の状態 push

Error

Server → Client

共通の status + message

opcode は機能帯で整理(例: アカウント 0x01、パーティ 0x10、入場 0x30)。下位が Req、0x80 以降が Rsp / Notify。

ファイル分割

  • common.proto — 座標・Item・AttrValue など共有型
  • gateway.proto — ログイン・パーティ・入場・MapTransferNotify
  • world.proto — 戦闘・移動・インベントリ・WorldStateNotify
  • GamePacketCodec — Gateway / World 共通のエンコード・デコード

opcode の規則

  • 0x01〜0x7F — クライアントからの Req(接続ごとに独立した空間)
  • 0x80〜 — サーバーからの Rsp / Notify
  • 0x01 帯 — アカウント、0x10 — パーティ、0x30 — 入場
  • OpcodeContractTests で値と一意性を CI 固定

メッセージ設計の原則

  • Req には結果値を載せない — StrikeEnemyReq は敵 UID のみ、ダメージは送らない
  • 確定結果は Rsp か Notify で返す — InventoryNotify、WorldStateNotify など
  • 入場系 Notify は接続情報だけ — MapTransferNotify(ホスト・ポート・トークン)
  • 投票系は requestId + accept で状態遷移を固定し、二重応答を防ぐ

04 / Authority

入力と確定

サーバー権威の中核。クライアントの表示用ステータスやアニメーションは検証対象外で、ゲーム結果だけをサーバーが決める。

意図と確定の分離

クライアントは「何をしたいか」だけ送る。ダメージ・所持数・最終座標はサーバーが計算してから Notify / Rsp で返す。

クライアントが送る

StrikeEnemyReq → 敵 UID のみ

MoveReq → 座標

PickupReq → ドロップ UID

World が確定

射程・命中・ダメージ再計算

速度エンベロープで位置検証

ドロップ消費 → 付与

攻撃(StrikeEnemyReq)

  • クライアント — 対象の敵 UID のみ送信
  • サーバー — 射程(XZ 距離)・生存・DamageCalculator でダメージ算出
  • 結果 — WorldStateNotify で HP 変化とノックバックを配信

移動・拾得・装備

  • 移動 — 報告座標を速度エンベロープで検証。超過時は PositionCorrectNotify
  • 拾得 — 距離と存在確認後、DropRegistry.TryConsume で先に消費してから付与
  • 装備 — 在庫とスロット制約を検証し、有効ステータスを再計算して Notify

接続時の再計算

  • World 入場時に MongoDB から装備込みステータスを読み直す
  • 戦闘中に参照する HP・攻撃力はすべてサーバー側キャッシュ
  • クライアントがローカルで書き換えても、サーバー結果には影響しない

05 / Sync

同期と整合

リアルタイム処理は同期、永続化は後追い。切断・再接続・非同期保存のズレを前提に、メモリ上の正と DB の遅れを切り分ける。

リアルタイムと永続化の境

プレイ中の正は World メモリ。DB 書き込みは後追いで、完了時に接続の整合性を確認する。

WorldCommandQueue

攻撃・拾得・装備などを 1 本のキューで直列処理

メモリ(正)

HP・位置・ドロップ・敵 AI

MongoDB(保存)

非同期・楽観ロック

保存完了時に接続が切り替わっていたら結果を破棄。インベントリは version で競合を検知。

WorldCommandQueue

  • 攻撃・拾得・装備などプレイヤー操作を 1 キューに直列投入
  • 同一プレイヤーの並列 Req による競合を防ぐ
  • 敵 AI も同じルーム状態を参照し、一貫したダメージ判定

永続化の境界

  • インベントリ — version 付き楽観ロック。競合時はクライアントへエラー
  • 位置保存 — 通常はスロットル。切断・マップ転送時は即時 flush
  • 非同期保存完了時 — IsCurrentPeer で接続が同じか確認、違えば破棄

トレードオフ(正直なところ)

  • 移動はクライアント報告+速度検証。完全なサーバー側シミュレーションではない
  • 攻撃レートのサーバー検証は段階的に強化予定(現状は射程・ダメージ再計算が主)
  • 表示用アニメーションは信頼しない。ゲーム結果は Notify / Rsp のみを正とする

06 / Threat Model

不正行為の想定

想定する不正・設計上の対策・その限界・次の改善をセットで管理する。機能追加のたびにこの表を更新する。

Threat Model の見方

各シナリオを 4 段で追う。対策だけ書いても、突破条件と次の手がセットでないと設計として未完。

1不正の試み
2設計上の対策
3限界(まだ弱いところ)
4次の改善

攻撃を異常な頻度で送る

対策
射程・生存・ダメージは DamageCalculator で毎回再計算。クライアント値は使わない
限界
攻撃間隔のサーバー検証が未実装のため、射程内連打は通りうる
次にやること
攻撃 CD のサーバー側検証と opcode レート制限を追加

移動速度を改ざんする

対策
前回受理位置からの移動距離を速度上限で検証。超過時は PositionCorrectNotify
限界
Y 軸や微小な加速は検証粒度次第で通る余地がある
次にやること
サーバー側移動確定または経路の再計算へ段階移行

射程外の対象に攻撃する

対策
サーバー上の XZ 距離で射程判定
限界
プレイヤー位置自体が改ざんされていれば距離判定も信頼できない
次にやること
移動の信頼性を上げ、攻撃時に直前の移動履歴も参照

同一ドロップを二重に拾う

対策
ルーム内で DropRegistry.TryConsume を先に実行。消費できなければ PickupReq は失敗
限界
キュー順で緩和されるが、設計によっては競合窓が残る
次にやること
消費と付与を一連トランザクション化し、監査ログを残す

world_token を別端末で使う

対策
短命 TTL・Redis 上の単発消費・接続所有者(IsCurrentPeer)の一致
限界
有効期限内なら 1 回は入場できる(単発入場トークンの性質)
次にやること
KCP 接続キーと入場を紐づけ、トークン単体では再接続不可に

クライアントのステータスを書き換える

対策
入場時に DB から装備込みステータスを再計算。戦闘はサーバーキャッシュのみ参照
限界
ローカル UI は変わるが、サーバー結果には影響しない
次にやること
結果はすべて WorldStateNotify / InventoryNotify を正とする

所持していない装備を装備する

対策
在庫存在とスロット制約をサーバーで検証してから EquipItemRsp
限界
DB 層への直接不正アクセスは別レイヤーの問題
次にやること
楽観ロックと操作ログで異常な変更を検知

守れないものはクライアント検証に頼らず、サーバー再計算かレート制限へ寄せる。未実装の対策は「限界」に明示し、後続タスクに残す。