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 はトークン検証後にだけ関与する。
ログイン
Gateway で認証 → session_token
マップ選択
Gateway が World インスタンスを決定
パーティ(任意)
Gateway で投票 → 全員同意
入場
world_token 発行 → World へ接続
プレイ
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 空間は接続ごとに独立。
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 段で追う。対策だけ書いても、突破条件と次の手がセットでないと設計として未完。
攻撃を異常な頻度で送る
- 対策
- 射程・生存・ダメージは DamageCalculator で毎回再計算。クライアント値は使わない
- 限界
- 攻撃間隔のサーバー検証が未実装のため、射程内連打は通りうる
- 次にやること
- 攻撃 CD のサーバー側検証と opcode レート制限を追加
移動速度を改ざんする
- 対策
- 前回受理位置からの移動距離を速度上限で検証。超過時は PositionCorrectNotify
- 限界
- Y 軸や微小な加速は検証粒度次第で通る余地がある
- 次にやること
- サーバー側移動確定または経路の再計算へ段階移行
射程外の対象に攻撃する
- 対策
- サーバー上の XZ 距離で射程判定
- 限界
- プレイヤー位置自体が改ざんされていれば距離判定も信頼できない
- 次にやること
- 移動の信頼性を上げ、攻撃時に直前の移動履歴も参照
同一ドロップを二重に拾う
- 対策
- ルーム内で DropRegistry.TryConsume を先に実行。消費できなければ PickupReq は失敗
- 限界
- キュー順で緩和されるが、設計によっては競合窓が残る
- 次にやること
- 消費と付与を一連トランザクション化し、監査ログを残す
world_token を別端末で使う
- 対策
- 短命 TTL・Redis 上の単発消費・接続所有者(IsCurrentPeer)の一致
- 限界
- 有効期限内なら 1 回は入場できる(単発入場トークンの性質)
- 次にやること
- KCP 接続キーと入場を紐づけ、トークン単体では再接続不可に
クライアントのステータスを書き換える
- 対策
- 入場時に DB から装備込みステータスを再計算。戦闘はサーバーキャッシュのみ参照
- 限界
- ローカル UI は変わるが、サーバー結果には影響しない
- 次にやること
- 結果はすべて WorldStateNotify / InventoryNotify を正とする
所持していない装備を装備する
- 対策
- 在庫存在とスロット制約をサーバーで検証してから EquipItemRsp
- 限界
- DB 層への直接不正アクセスは別レイヤーの問題
- 次にやること
- 楽観ロックと操作ログで異常な変更を検知
守れないものはクライアント検証に頼らず、サーバー再計算かレート制限へ寄せる。未実装の対策は「限界」に明示し、後続タスクに残す。