オブジェクト同期の仕組み
sync と stateMachine のルームで、オブジェクトがどう同期され、誰が何を書き換えられるかを説明します。このページの内容は、ゲームエンジンによらず共通です。エンジンごとの設定と API は、Unity クライアントと Godot クライアントを参照してください。
全体像
| 用語 | 意味 | 敵が 10 体いる場合の例 |
|---|---|---|
| 共有オブジェクト | ルームが作り、参加者全員が同じ状態を参照する対象 | 10 体の敵 |
| プレイヤーオブジェクト | プレイヤーが生成し、所有するオブジェクト。所有者が値を書き換える | 各プレイヤーのキャラクター |
| オブジェクト ID | ルーム内の 1 つの実体を表す ID。サーバーが発行する | 敵ごとに異なる 10 個の ID |
| Prefab ID | オブジェクトの種類を表す ID。クライアントで表示する Prefab(Godot ではシーン)を選ぶのに使う | 10 体とも同じ ID |
| 同期プロパティ(フィールド) | 参加者へ配信する値 | 位置、HP |
オブジェクトの種類によって、値を書き換えられる人が決まります。
| 種類 | 値を書き換える人 | 例 |
|---|---|---|
| プレイヤーオブジェクト | 所有者 | 各プレイヤーのキャラクター |
| 操作権を貸し出す共有オブジェクト | 操作権を持っている参加者(操作権) | 持ち運ぶ箱、パドル |
| サーバー管理の共有オブジェクト | ルールだけ。参加者は操作を送る | 敵、扉 |
| Simulated オブジェクト | ルールだけ。所有者は入力を送る(ティック) | 入力で動くキャラクター |
データは次の順番で届きます。
- ルームに参加すると、その時点の全オブジェクトと値(スナップショット)が届き、クライアントが表示用のインスタンスを生成します。
- 以降は、変更された値だけが届き、表示が更新されます。
送信の順序
RPC・生成・イベント・プロパティなどの送信は、接続後ならいつ呼んでもかまいません。スナップショットの反映前や、生成の通知の中で呼んだものは、送れるようになった時点で、呼んだ順に送られます。送信の順序は、すべてのオブジェクトとルーム全体の操作を通して保たれます。
受信したメッセージにはルームのサーバーの時刻が付いています。クライアントはこれを使って、参加者に共通の時刻を持ち、他のプレイヤーのオブジェクトを受信時刻に沿って滑らかに動かします。
位置・回転
位置・回転は標準のフィールドがあり、同期スキーマがなくても同期できます。サーバー側で同期を許可した軸だけを、クライアントで選びます。
| サーバーの宣言 | 同期できる軸 | 刻み幅(標準) |
|---|---|---|
RoomSync.Position | 位置 X / Y / Z | 0.01(ゲームの距離単位) |
RoomSync.Rotation | 回転 X / Y / Z | 360 / 4096 度 |
RoomSync.Transform | 上記の両方 | 上記と同じ |
- 位置はワールド座標、回転はオイラー角として扱います。Scale は同期しません。
- 他のクライアントでは、少し過去(既定 0.1 秒前)の姿勢を、前後に受信した更新の間で補間して表示します。一定の距離より離れた位置へは、補間せずに移動させることもできます(リスポーンなど)。
- 更新が止まると、最後に受信した姿勢で止まります。先の位置を予測することはしません。
- 値は刻み幅に丸めて送ります。刻み幅より小さな揺れは送信されません。
独自の値
HP や開閉状態などの独自の値は、ルールで同期スキーマとして宣言し、生成された .realtimeschema をクライアントで使います。宣言の書き方は同期スキーマを参照してください。
- 書き込める側が値を読むと、自分が最後に書いた値(送信前でも)が返ります。たとえば HP に 1 を足す書き込みを、送信の間に 2 回行うと 2 増えます。
- ルームから受信した値だけを読む方法(confirmed)もあります。
- 受信した値を表示に渡す仕組み(Unity の Property bindings、Godot の
property_bindings)は、表示を変えるだけです。サーバーの値は変わりません。サーバーが管理する値は、操作を送ることで変更します。
操作権
サーバーが「操作権を貸し出す」と宣言したオブジェクトは、操作権を得た参加者だけが値を送信できます。箱を持ち運ぶ、パドルを動かす、といった用途に使います。
- 取得の要求は非同期で処理されるので、要求した直後に操作できるとは限りません。
- ほかの参加者が操作権を持っているときは、
LeaseBusyで拒否されます。拒否された場合は自動で再要求しません。 - 操作権には期限があり、クライアントは期限が切れる前に自動で更新できます。
- 操作できる間だけ、移動や入力の処理を有効にできます。共有オブジェクトでは操作権がある間、プレイヤーオブジェクトと Simulated オブジェクトでは所有している間です。
- 操作権を待つ要求の結果は、次のいずれかです。
| 結果 | 意味 |
|---|---|
| 成功 | 操作できるようになった |
| 拒否 | LeaseBusy:ほかの参加者が操作権を持っている。Forbidden:操作権を取得できないオブジェクト(サーバー管理のオブジェクトや、ほかのプレイヤーのオブジェクト) |
| 取り下げ | 要求を取り下げた(操作権の返却、表示の無効化、オブジェクトの削除) |
- 表示用のインスタンスが無効になると、操作権は自動で返却されます。
- 操作権を手動で取得・延長・解放する API(
Acquire・Renew・Release)もあります。これはルームの応答をそのまま返すだけで、処理の有効化や自動更新は行いません。 - 操作権の保持者が替わる(取得・返却・期限切れ)たびに、新しい期間になります。前の期間に送られた値(UDP で遅れて届いたもの)は反映されず、エラーにもなりません。
サーバーに操作を送る
サーバーが管理するオブジェクト(敵の HP など)は、クライアントから値を書き換えられません。代わりに「このオブジェクトに method の操作をする」という要求(Interact)を送ります。要求はルールの Interact(GDScript では on_interact)に届き、ルールが検証して値を更新します。
method は、ルールで操作の種類を見分けるための番号です。ルールが [RoomAction] の enum(GDScript では ## @room_action)で名前を付けていれば、生成された定数をそのまま渡せます。ルールで動くルームには、追加の引数は送れません。
操作の要求(と操作権の取得・延長・解放)は、一度だけ完了します。完了の状態は次のいずれかです。
| 状態 | 意味 |
|---|---|
| 成功(Succeeded) | 受理された |
| 拒否(Refused) | 拒否された。理由は結果コード(下の表) |
| 未送信(NotSent) | 送信できなかった |
| 取り下げ(Abandoned) | 応答が来なくなった(切断した、または再接続でルームに入り直した) |
対象を消す操作(アイテムの取得など)でも、ルームはオブジェクトを消したあとに結果を返すため、成功で完了します。
| 結果コード | 値 | 意味 |
|---|---|---|
Success | 0 | 受理され、変更が全員に配信された |
MissingObject | 1 | 対象のオブジェクトがない |
Forbidden | 2 | このオブジェクトには許可されていない操作 |
LeaseBusy | 3 | ほかの参加者が操作権を持っている |
StaleEpoch | 4 | 操作権の状態が変わった後の古い要求 |
InvalidOperation | 5 | 今の状態では受け付けない操作、またはルールの実行エラー |
AlreadyApplied | 6 | ルールがすでに適用済みとして返した。クライアントでは成功として扱う |
StaleRequest | 7 | 同じ要求 ID がすでに処理済み |
| ルールの拒否理由 | 100 以上 | ルールが Step.Ignore で返した値 |
ルールが Step.Ignore で拒否した場合は、その理由の数値がそのまま届きます。ルームの結果と区別できるよう、ルール独自の拒否理由には 100 以上の値を使ってください(エラー enum)。
プレイヤーオブジェクト
プレイヤーが操作するキャラクターなど、プレイヤーが所有するオブジェクトは、クライアントから生成を要求して作ります。生成の方法は 2 つあります。
| 方法 | 動作 |
|---|---|
すぐに作る(Unity の world.Instantiate、Godot の world.instantiate) | インスタンスをすぐに返し、自分の所有として使える。移動や RPC は、ルームが生成を確定するまで保留され、確定後に送られる。確定までに動かした位置は保たれる。同期する値は確定後に使える |
確定を待って作る(world.Spawn / world.spawn) | ルームの確定を待ってから、インスタンスを作る |
ルームが生成を拒否すると(所有数の上限 64 の超過や、オブジェクトカタログを設定したルームでの未登録の Prefab)、拒否が通知され、すぐに作ったインスタンスは削除されます。
- 所有権は、別の参加者へ渡せます(プレイヤーオブジェクトだけ)。オブジェクトカタログで
"ownership": "takeover"にしたオブジェクトは、誰でも所有権を取得できます。 - 所有者が退出すると、そのプレイヤーオブジェクトは削除されます。オブジェクトカタログで
"keepOnLeave": trueにしたオブジェクトは、マスタークライアントへ引き継がれます。 - 所有者が替わるたびに、所有期間が新しくなります。前の所有期間に送られた値(UDP で遅れて届いたもの)は、同じ参加者が所有権を取り戻した後でも反映されません。
RPC
オブジェクトのメソッドを、ほかのクライアントで呼び出します。
| 宛先 | 届く相手 |
|---|---|
| All | 呼び出したプレイヤーを含む全員 |
| Others | 呼び出したプレイヤー以外 |
| Owner | オブジェクトの所有者 |
| AllBuffered / OthersBuffered | 上記に加え、あとから参加したプレイヤーにも届く。オブジェクトの削除か、呼び出したプレイヤーの退出で破棄される |
| 1 人を指定 | 指定した接続 ID の参加者 |
- RPC の ID は、メソッド名から決まります。メソッド名を変えると ID も変わるので、オブジェクトカタログとクライアントをそろえて更新してください。
- 誰がどの RPC を呼べるかを制限したい場合は、オブジェクトカタログを設定します。設定すると、
methodsに登録した ID を所有者だけが呼べるようになり、ほかのプレイヤーのオブジェクトにも呼ぶ RPC はpublicMethodsに登録します。 - 引数は合計 4096 バイトまでです。最後の引数で、呼び出し元の接続 ID などを受け取れます。
- 受信した RPC は、届いた順に、受信処理を終えてから呼ばれます。RPC の中から RPC を送ったり値を書き換えたりできます。
- 共有オブジェクトには RPC を送れません。操作を送るを使います。
ルームイベント
オブジェクトに紐づかない通知は、ルームイベントで送ります。参加者なら誰でも送れます。宛先は All / Others と、それぞれの Buffered、または 1 人です。Buffered のイベントは、送ったプレイヤーが退出すると破棄されます。
カスタムプロパティ
ルームやプレイヤーに付ける値です。あとから参加したプレイヤーにも届きます。
- 値に使える型は RPC の引数と同じです。
nullを書くと削除します。 - 書き込みは、ルームを経由して全員に届いた時点で反映されます。書いたプレイヤーの手元でも同じなので、全員が同じ順番で値を見ます。
- プレイヤーのプロパティは、自分のものだけ書けます。プレイヤーが退出すると、そのプレイヤーのプロパティは削除されます。
- キーは 64 バイトまで、ルーム全体で 1024 件までです。
マスタークライアント
ルームは参加者のうち 1 人をマスタークライアントにします。最初に参加したプレイヤーがマスターになり、マスターが退出すると、残りのうち最も早く参加したプレイヤーへ移ります。マスターは、ほかの参加者をマスターに指名できます。
ゲームの進行をサーバーで決めたい場合は、マスタークライアントではなく stateMachine のルールを使うと、クライアントの改造の影響を受けません。
Interest Group
オブジェクトをグループに分け、購読しているプレイヤーにだけ更新を届けます。グループは所有者が決め、グループ 0 は全員に届きます。
- グループで絞り込まれるのは、値の更新と、そのオブジェクトへの All / Others 宛ての RPC です。生成・削除・所有者やグループの変更は、全員に届きます。
- 購読すると、そのグループのオブジェクトの現在値がすぐに届きます。
- 購読は再接続しても引き継がれます。1 接続あたり 256 グループまでです。
ティックと予測
ルームのルールがティックと Simulated オブジェクトを定義する場合、その種類のオブジェクトはサーバーが入力から動かします(ティック)。クライアントは位置を書き込まず、入力を送ります。
- 入力は 32 個の整数スロットの「現在の状態」です。ルームのティックごとに送られます。範囲を指定したスロットは、ルームが範囲内に丸めて読みます。
- 自分が所有する Simulated オブジェクトがある間だけ送ります。1 回の送信に直近 3 ティック分の入力を入れるので、UDP で 1 つ落ちても補えます。
- クライアントは、入力がルームの処理より少し(既定 2 ティック)早く届くように、入力に付けるティック番号をルームより先に進めます。ルームから届く時刻を見て、進み方を ±10% 以内で自動調整します。
- Simulated オブジェクトは、所有者でも値の書き込みも削除もできません。削除はルールの
Despawnか、所有者の退出で行われます。
ルールに予測の処理(予測)があると、コンパイラがクライアント用の予測のコードを出力します。登録すると、次のように動きます。
- 自分が所有する Simulated オブジェクトは、入力したティックの結果をすぐに表示します。往復の通信時間を待ちません。
- ルームの計算結果が届くたびに、その結果から現在のティックまで計算し直します(ロールバック)。予測が外れていた場合は、ずれを約 0.1 秒で目立たないように詰めます。
- ほかのプレイヤーのキャラクターは、ルームの結果を補間して表示します。
- 予測の中で生成されたオブジェクト(予測スポーン)は先に表示され、ルームのオブジェクトが届くと同じインスタンスに引き継がれます。予測で削除されたオブジェクトは、ルームが削除するまで描画が止まります。
- 入力には、そのときほかのオブジェクトが画面上でどれだけ遅れて表示されていたかが付きます。ルームはこれを使って、撃った側の画面どおりに当たり判定をします(ラグ補償)。正確に計算するには、クライアントにサーバー時刻を渡し、表示の遅れ(View Delay)を位置の補間の遅れと同じ値(既定 0.1 秒)にします。
UDP
位置などの定期的な更新を UDP で送ると、通信が詰まったときの遅延を抑えられます。生成・RPC・イベント・プロパティなど、届かないと困るものは WebSocket のままです。
- クライアントはルームへの登録と登録の維持をします。ルームから応答があると UDP を使い、10 秒応答がないと WebSocket に戻ります。
- UDP で送った値は、ルームからの値もクライアントからの値も、取りこぼしに備えて 1 秒ごとに WebSocket で送り直されます。UDP がしばらく届かなかった場合も、通信が戻れば約 1 秒以内に最新の値になります。
- 所有権を渡すときや操作権を返すときは、その前に UDP で送った値を WebSocket で送り直します。新しい書き手は、最新の状態を WebSocket で受け取ってから書き込みを始めます。
- 状態の更新には、送った順の番号が付きます。UDP と WebSocket で届く順序が入れ替わっても、古い値が新しい値を上書きすることはありません。予測に使うルームの状態も、届いた順ではなくこの番号で組み立てます。
- RPC などの直前に送る値は WebSocket で送るので、RPC より先に届きます。
- Web ブラウザ(Unity の WebGL、Godot の Web 版)では UDP を使えません。WebSocket だけで動作します。
オブジェクトカタログ
ルームに、プレイヤーオブジェクトの種類・フィールド・RPC・所有権の要求を検査させたい場合は、Realtime のネームスペース設定の syncSetting.objectCatalog に、検査の基準を JSON で設定します。キーは Prefab ID です。
設定しない場合、ルームはこれらを検査しません。登録のない Prefab も生成時に宣言したフィールドを持つプレイヤーオブジェクトとして生成でき、どの RPC も誰でも呼べ、誰でも所有権を取得できます。設定した場合は、登録した Prefab・フィールド・RPC だけが通ります。
{
"1": {
"fields": 63,
"mode": "player",
"methods": [1],
"publicMethods": [2],
"ownership": "takeover",
"keepOnLeave": true
}
}| 項目 | 内容 |
|---|---|
fields | 同期するフィールドのビットマスク。位置 X/Y/Z が 1/2/4、回転 X/Y/Z が 8/16/32 |
mode | プレイヤーオブジェクトは "player" |
methods | 所有者が呼べる RPC の ID |
publicMethods | 参加者の誰でも、ほかのプレイヤーのオブジェクトに対して呼べる RPC の ID |
ownership | "fixed"(既定)か "takeover"(誰でも所有権を取得できる) |
keepOnLeave | true なら、所有者の退出時に削除せずマスタークライアントへ引き継ぐ。この動作はカタログに登録した Prefab でだけ使える |
手で書く代わりに、エディタからカタログの JSON を生成できます(Unity・Godot)。ルールが定義する Prefab(共有オブジェクトと Simulated オブジェクト)は、生成される JSON に含まれません。カタログとルールの両方で同じ Prefab ID を定義すると、ルームの初期化が失敗します。
オブジェクトの寿命と再接続
| 対象 | 切断したとき | 再接続したとき |
|---|---|---|
| サーバーが作った共有オブジェクト | ルームに残る | スナップショットで、その時点の値のまま再表示される |
| 自分のプレイヤーオブジェクト | 削除される(keepOnLeave ならマスターへ引き継がれる) | 必要なら作り直す |
| 自分のプレイヤープロパティ・Buffered の RPC とイベント | 削除される | 必要なら設定し直す |
| ルームのプロパティ | ルームに残る | スナップショットで届く |
| 持っていた操作権 | 返却される | 必要なら取得し直す |
| 自分の接続 ID | 無効になる | 新しい ID が発行される |
再接続のたびに、届いたスナップショットから表示を作り直します。クライアントでルールや操作履歴をやり直す必要はありません。再接続の前に待っていた要求は、取り下げ(Abandoned)で完了します。
同じ userId で接続し直すと、古い接続は退出したものとして扱われます。
ルームの終了条件との関係
ルームは、参加者全員から 1 分間通信がないと終了します。sync と stateMachine のルームでは、接続を保つための Ping は、この通信に数えません。
クライアントは値が変わったときだけ送信します。UDP の登録の維持も、この通信には数えません。そのため、全員が操作せずに待つ場面があるゲームでは、その間もルームが続くよう、定期的に操作を送るなどの設計が必要です。
送信量の目安
同期データは、フィールド名を送らず、変更されたフィールドの番号と整数値だけを送ります。クライアントは、一定の間隔(既定 20 回/秒)で変更をまとめて送信します。同じ値を何度書いても、前回送った値に戻っても、送信量は増えません。
位置の刻み幅と同期する軸を必要な範囲に絞ると、小さな揺れによる送信を減らせます。
うまく動かないとき
エンジンに共通の確認事項です。エンジンごとの確認事項は、Unity クライアントと Godot クライアントを参照してください。
| 症状 | 確認すること |
|---|---|
| オブジェクトが表示されない | 接続後にスナップショットを受信しているか。届く Prefab ID がクライアントのカタログに登録されているか |
| 値の大きさや位置がずれる | ルール(GSL)と同期スキーマを、同じルールの定義から同時に生成したものに揃えているか。ずれていると、クライアントが「generated from other rules」の警告を出す |
| 操作が拒否される | 拒否の通知で、拒否されたコマンド・対象の ID・理由(Forbidden:権限がない、MissingObject:オブジェクトがない、LimitReached:上限、MethodNotAllowed:カタログにない RPC など)を確認する |
| 攻撃などの操作が反映されない | 要求の結果を確認する。結果コードの一覧を参照 |
| RPC が届かない | オブジェクトカタログを設定している場合、RPC の ID がカタログの methods(ほかのプレイヤーから呼ぶなら publicMethods)にあるか。メソッド名を変えると ID も変わる |
| 生成したオブジェクトが消える | 生成の拒否の結果を確認する。所有数の上限(64)を超えていないか。オブジェクトカタログを設定している場合、Prefab が "mode": "player" で登録されているか |
| UDP が使われない | ルームのポートが UDP で開いているか。Web ブラウザでは UDP を使えない |