play-server
内部ドキュメント
このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。
部屋の state は play-server の Durable Object が唯一の権威として持つ。クライアントは action (意図) だけを送り、サーバーがシナリオの reducer で検証・実行して結果を全員へ配る。
この設計が満たすもの
- 全プレイヤー平等: ホスト / ゲストの区別が無い。全員が DO に対して同じ立場で、経路も Client → DO → Client で揃う
- チート不可能: クライアントは任意の state 変更ができない。通るのは reducer が受理した action だけ
- 安定した切断復帰: DO が state を永続化するので、再接続時に最新 state をそのまま受け取れる
- state の寿命をコードから分離: 作家コードの isolate (facet) が持つ SQLite storage は play-server と同寿命。「中断して 1 ヶ月後に再開」が成立する
アーキテクチャ
room ごとに 1 個の GameHost DO (idFromName(\${revisionId}😒{roomId}`)`) が立ち、内部が 2 層に分かれる。
役割分担の要点: facets は transport (WebSocket / alarm) が未実装なので、transport は supervisor に集約し、facet は logic 実行と state 保持だけを担う。この分担は UGC の権限最小化としても最も筋が良い(作家コードはソケットに触れず、外向き fetch も allowlist されたホストにしか出られない)。
全クライアントが対等。 state の権威は常に DO 側にある。
UGC 開発者のコード
// src/logic.ts — サーバーに送られるファイル
// GameLogic オブジェクトを default export する
const logic: GameLogic<MyState> = {
setup({ players, ctx }) { ... },
// クライアント先読み + サーバーの 2 回走る。決定的でなければならない
actions: { ... },
// サーバーでのみ 1 回走る。ctx に { tick, random }、async 可
serverActions: { ... },
update({ state, ctx }) { ... },
tickRate: 10,
};
export default logic;
// src/main.ts — クライアント専用(サーバーには送られない)
import logic from "./logic";
playScreen.run({
logic,
onState(state, myPlayerId) { /* state を受け取って自由に描画 */ },
inputs(sendAction) { /* 入力ハンドリング */ },
events: { ... },
});manifest.json の変更
serverActionLogicPath にサーバーで実行するロジックのエントリポイントを指定する。state 同期を使わない 1 人用シナリオ (ブリッジだけを使うもの) では省略できる。
{
"id": "snake-battle",
"name": "Snake Battle",
+ "serverActionLogicPath": "./src/logic.ts",
"playerCount": 2
}manifest.json の型定義
interface Manifest {
id: string; // ゲーム ID(グローバルに一意)
name?: string; // 表示名
serverActionLogicPath?: string; // ServerAction 用ロジックのエントリポイント。指定すると ServerAction モードになる
playerCount?: number; // プレイヤー数
build?: string; // ビルドコマンド
output?: string; // ビルド出力ディレクトリ
}| フィールド | デフォルト | 説明 |
|---|---|---|
serverActionLogicPath | ― | サーバー実行するロジックのエントリポイント(例: "./src/logic.ts")。省略すると logic を配らない (state 同期なしの 1 人用シナリオ) |
playerCount | ― | プレイヤー数 |
GameHost / GameFacet の実装
実装は javascript/uzu_code/play-server/ にある。
| クラス | ファイル | 役割 |
|---|---|---|
GameHost | src/game-host.ts | supervisor。WS 保持 (hibernation API)・__ping autoResponse・tick ループ (setInterval)・alarm (締切中継 + heartbeat)・facet の生成 / コード更新・今の接続の一覧を facet へ渡す・無言の切断を閉じる |
GameFacet | src/game-worker-template/game-facet.ts | facet。Durable Object の皮。GameRoomCore を作り、DO の SQLite storage を永続化ポートとして渡し、全 RPC の入口で hydrate() を通すだけ |
GameRoomCore | javascript/uzu_code/room-core/src/room-core.ts | ゲームルーム本体。logic (reducer) の実行と state 保持。transport を一切持たない |
GameRoomCore は @uzuhq/code-room-core として uzu dev の dev-server とも共有する。 両者が足すのは transport (WS / tick ループ / 起床) と永続化と dev フックだけなので、 「dev では止まるのに本番で止まらない」は構造的に起きない。 起床の予約規則だけは両者で違う (下記 alarm 中継) ため core の外に残してある。
RPC 面 (FacetOutcome)
facet は supervisor からの RPC に対して「送るべきメッセージ」を返すだけの純粋な実行器として振る舞う。
interface FacetOutcome {
toSender: string[]; // RPC の契機となった接続にだけ送る JSON 文字列
broadcast: string[]; // 全接続に送る JSON 文字列
nextDeadline: number | null; // state 由来の次の締切 (supervisor が自分の alarm に写す)
tickRate: number; // supervisor の tick ループ制御用
started: boolean; // ゲーム開始済みか
frozen: boolean; // 時計が止まっているか (緊急停止 / 無人凍結)
}
// 今つながっている接続 1 本 (supervisor が attachment から組む)
interface LiveConnection {
connectionId: string;
seatId: string;
monitor: boolean; // 監視で開いた接続
}
// facet の RPC メソッド
onConnect(connection, rosterParam, live) // 接続受け入れ (__state / __game_start / presence を返す)。live は受け入れる接続を含む
onMessage(sender, msg) // __action / __request_state / __input / __platform / __pause / __resume
onTick() // tick 1 周分 (supervisor の setInterval から)
onAlarm(live) // 接続の一覧との突き合わせ + 期限が来た締切 + 無人猶予の満了 + 切断の確定
onDisconnect(connection, live) // playerInputs の後始末 + 接続の一覧との突き合わせ。live は切れた接続を除くsupervisor は facet の RPC の結果(toSender / broadcast / nextDeadline / 時計)を、どの経路でも deliver 1 か所で配って反映する。経路ごとに書くと、どこかで配り忘れても dev server(配信を 1 か所で行う)では再現しない。
facet は接続を自分で数えない。supervisor が接続・切断・起床のたびに、readyState が OPEN のソケットから今の接続の一覧を組んで渡し、facet は一覧と突き合わせて 接続と通話の状態 と無人凍結を決める。
nextDeadline は実時刻で返す。締切はシナリオのゲーム内時刻で 宣言されるが、変換は facet 側で閉じているので supervisor はゲーム内時計を知らない。
nextDeadline を毎 RPC の返り値に同梱するのは supervisor-pull 型 alarm 中継の追加往復を無くすため(facet は自前 alarm を持てない: "Facets currently cannot set alarms.")。
RPC の構造税は実測 ~0.17ms/往復 (4KB clone 込み)。60fps tick でも予算の 1.2%。
なぜ action の逐次実行が保証されるか
facet も Durable Object と同じく 1 インスタンスにつきシングルスレッド で動作する。supervisor の webSocketMessage() が facet への RPC 待ちで interleave しても、facet 側で action は到着順に逐次実行 (FIFO) され、reducer は常にその時点の最新 state に対して実行される。race condition は起きない。
Attachment(各 WebSocket に付与)
WS は supervisor が持つ。attachment に各接続のメタデータを載せる。
interface Attachment {
connectionId: string; // サーバー自動生成 UUID(ログ・デバッグ用)
playerId: string; // クエリパラメータで指定されたプレイヤー ID
nickname: string; // 表示名
monitor: boolean; // 監視で開いた接続
connectedAt: number; // 受け入れた時刻(無言の切断の判定の起点)
}connectionId: 接続ごとに一意。同一プレイヤーの複数接続を区別するために使用playerId: クエリパラメータ?seatId=xxxで指定。State とメッセージルーティングで使用nickname: クエリパラメータ?nickname=xxxで指定。表示名として使用monitor: クエリパラメータ?monitor=1で指定。presence に数えず、緊急停止の要求も通話の申告も受けない。UZU ADMIN のイベント詳細や Studio のテストプレイ履歴から開いたエミュレータの各画面は、プレイヤーと同じ席で、本物のプレイヤーが遊ぶ部屋につながるためconnectedAt: heartbeat の自動応答がまだ一度も無い接続の、無言の判定の起点
roster はクエリパラメータ ?players=[...] で最初の接続時に確定する。載るのは配役を受け取る player 席だけで、観測者 (観戦席・進行管理席) は roster に載らないまま接続してくる。roster の解釈と pin は facet 側 (onConnect) が行う。
観測者の種別 (admin / spectator) はホストが iframe URL の ?seatKind= で client へ伝えるだけで、WebSocket には出さない。roster に居ない接続を通すだけで、その席が admin か spectator かを知る必要が無いため。
roster に自席が居ない接続を弾くことはしない。弾くには「観測者か、roster がズレた player か」を区別するフラグが要り、それは wire に無いためである。代わりに、申告 roster が pin 済み roster と食い違ったら facet が warn ログを出す。roster がズレた player は真っ黒ではなく静かに観測者ビューになるので、例外では気付けない。
なお uzu dev の dev-server GameRoom は manifest から組んだ roster を room 生成時に注入し (server 権威)、接続クエリの申告 roster は採用しない。roster の権威は本番では backend (Flutter が backend 由来の roster を申告する)、dev では CLI にあり、client の自由申告を信用しない点で両者は同じ信頼モデルに立つ。
facet 再起動と state 復元: hydrate() パターン
facet はコード更新 (facets.abort()) や supervisor の eviction で再起動され、インメモリの state / tickCount がリセットされる。facet の全 RPC 入口で core.hydrate() を呼び、専用 SQLite storage から state を復元する。
async onMessage(sender: LiveConnection, msg: string): Promise<FacetOutcome> {
await this.core.hydrate();
return this.core.message(sender, msg);
}復元そのものは GameRoomCore にあり、storage は 2 メソッド (get / put) のポートとして受け取る。dev-server はここに何も渡さない (揮発) ので hydrate() は即座に返り、復元の分岐は本番だけで走る。
tick ループの再開は supervisor 側の責務: 各 RPC の返り値 (started / tickRate) を見て、接続が 1 本以上あるときだけ setInterval を張り直す。全員退出済みの部屋で tick ループが走り出すことはない。
接続数は readyState が OPEN のソケットだけで数える。getWebSockets() は webSocketClose の中で閉じている当のソケットも返すため、そのまま数えると最後の 1 人が抜けても残接続 1 となり、tick ループと heartbeat が止まらない。
復帰バグに注意
onMessage() で hydrate() を呼ばないと、facet 再起動後の __action が state === null で無視され、プレイヤーの入力がサーバーに反映されない現象が起きる。 必ず全 RPC 入口で呼び出すこと。
Storage 永続化の遅延書き込み
tick や action のたびには storage.put() せず、最大 500ms 遅れでまとめて書く。ブロードキャストのクリティカルパスから I/O を除去するため。 書くのは予約が発火した時点の最新の値なので、窓の中の更新は 1 回の書き込みにまとまる。
| 方式 | tick ごとの I/O | broadcast レイテンシへの影響 |
|---|---|---|
毎 tick storage.put() | あり | storage I/O がブロック |
| 最大 500ms 遅れ | 最大 500ms に 1 回 | 影響なし |
予約済みなら張り直さない
更新のたびに予約を張り直す(trailing debounce)と、500ms より短い間隔で更新が続く限り一度も書かれない。 tickRate 3 以上の部屋では state キーが storage に届かず、hydrate() は state が無いと時計・roster も読まずに返るので、再起動のたびにゲームが最初から作り直される。
再起動で失うもの
facet の再起動(デプロイ・エンジンの入れ替え・eviction)では、最後の書き込み以降の最大 500ms 分の更新を失う。ack を返した直後の action も含まれる。 クライアントは再接続時の __state で state を丸ごと置き換えるので、失った分はクライアントの表示も巻き戻る。 停止の出入りだけは遅延させず即時に書く(緊急停止)。
無言の切断
電波が切れた端末は close を送らず、ソケットは OPEN のまま残る。supervisor は alarm のたびに、各ソケットの heartbeat への自動応答の最終時刻(getWebSocketAutoResponseTimestamp)を見て、60 秒(SILENT_DROP_MS)途絶えたものを閉じる。応答がまだ一度も無い接続は connectedAt を起点にする。
閉じたソケットは OPEN から外れるので、そのまま渡す接続の一覧から消え、facet が猶予を張って offline を確定する。SDK は 25 秒ごとに __ping を送るので、60 秒は 2 回ぶん落としたうえで余裕を見た値になる。
同一 playerId 複数接続対応
同一 playerId から複数の接続が来ても排他制御を行わない。
- action ルーティング: 同一 playerId の全接続からの action を受け付ける
- presence: 同じ席の接続が 1 本でも生きていれば
online、通話は良いほうの申告を採る - プレイヤー一覧はゲーム開始時に確定し、途中で増減しない前提
設計判断
旧接続を強制切断すると、エミュレータのように同一 playerId で複数の画面を開く構成で state 同期が壊れる。同一 playerId の排他制御は行わない。
Dynamic Workers (facets) とロードフロー
作家の logic.js は Cloudflare Dynamic Workers (Worker Loader) で実行時に動的ロードし、DO Facets で GameHost の子 isolate として起動する。revision ごとに script を焼く事前デプロイは存在しない。
codeId とロード手順
// GameHost 内 (概略)
const codeId = `${revisionId}:${TEMPLATE_VERSION}`;
this.ctx.facets.get("game", async () => {
const worker = env.LOADER.get(codeId, async () => ({
compatibilityDate: "2025-01-01",
mainModule: "index.js",
modules: { ...TEMPLATE_SOURCES, "logic.js": await r2LogicJs(revisionId) },
globalOutbound: env.DEVICE_EGRESS, // 素のインターネットは渡さない (下記)
}));
return { class: worker.getDurableObjectClass("GameFacet") };
});- R2 にはゲーム固有の logic.js のみ保存 (
{revisionId}/logic.js)。R2 は環境間で意図的に共有する(コードのみなので衝突しない。dev から prd と同じ logic.js を使えるようにするため) - ランタイムテンプレ(GameFacet エンジン)は play-server が所有 (
src/game-worker-template/)。codegen (scripts/build-template-sources.mjs) で各モジュールの.jsソースを文字列としてバンドルへインラインし、ロード時に logic.js と結合する - logic.js が R2 に無い場合(未 publish or 旧 worker-bundle 形式)は WS を upgrade せず 404 を返す
- cold start は実測 280〜330ms (R2 読込込み) / warm 0〜9ms。ゲーム zip のクライアントダウンロードと並行するため体感上の追加待ちは最小
外向き fetch (egress allowlist)
facet に素のインターネットは渡さない。globalOutbound には play-server 自身の DeviceEgress entrypoint (src/device-egress.ts) を service binding 経由で渡し、ホスト allowlist に載った宛先だけ通す。それ以外は 403 で落ちる。
- 現在の allowlist は UZU TOKYO 店舗機器 (
uzutokyo1.ngrok.app) のみ。公演演出でシナリオから照明・アナウンスを鳴らすために開けている - 宛先の正は uzutokyo DB の
rooms.device_url。allowlist はその複製なので、宛先を変えるときは両方を揃える - binding が無い環境では
globalOutbound: nullに倒れ、facet は完全に閉じる。undefinedを渡すと Worker Loader は親の権限を継承してインターネット全開になるので、未設定を「全開」に倒してはいけない - allowlist を変えたら
TEMPLATE_VERSIONを bump する。ロード設定は codeId 単位でキャッシュされるため、bump しないと稼働中の facet は旧設定のまま残る
state の寿命
facet は専用の SQLite storage を持ち、これは GameHost DO = play-server と同寿命。facets.abort() → 別 class 再起動をまたいで storage は保存される。旧 WfP JIT 方式の「cleanup が script を消すと DO namespace が巻き添えになり進行中 room の state が消失する」問題は構造的に存在しない。
終局した部屋の state は現状永遠に残る (state ~KB 級なので 100 万部屋でも月 $1 未満)。ライフサイクルポリシー(終局 N 日後に facets.delete() 等)はプロダクト都合で後日決める。
alarm 中継 (supervisor-pull 型)
facet は alarm を張れないため、締切 (logic.deadlines) は supervisor が中継する。facet の全 RPC が nextDeadline を返し、supervisor がそれを自分の alarm に写す。alarm 発火時は facet.onAlarm() を呼び、facet が期限の来た締切だけを発火する(発火直前に at を再評価するので at-least-once の重複発火は自然に弾かれる)。実測で中継 lag 0ms。
facet に supervisor への binding は渡さない(権限最小)。
heartbeat (beta バグ回避)
facet に触れた DO は evict 時にソケット駐車が壊れ、WS が 1006 で落ちる beta バグがある (upstream 既報: cloudflare/workerd#6800 ほか)。回避として、supervisor は接続が 1 本以上ある間だけ 60 秒ごとに alarm で実 wake し、壊れた evict 経路を踏ませない。
- 副産物として沈黙明けの facet 再構築も 0ms になる(isolate が温存される)
- 課金上限 ≈ $0.006/時/room。プレイ中の卓のみで、全員退出で自動停止し空室はゼロ
- #6800 修正後に撤去する
heartbeat と締切は 1 本の alarm に統合する。規則は「desired = min(締切, heartbeat)。現在の alarm より早い時だけ set し、後ろ倒しは絶対にしない」。締切が延びた場合は旧時刻で早発火 → facet が期限未到来で空振り → 再アームで自己修復する。後ろ倒しを許すと、遠い締切が heartbeat を上書きして evict 経路を踏む隙間が生まれる。
DO の配置 (locationHint)
room の DO は作成時に配置が固定され移動しない。混在部屋で初回接続者が遠隔地に居ると部屋ごと遠隔に固定される(リージョン外からのアクセスは実測 +230〜250ms)。
/ws/games/...?loc=<hint> でイベント開催地由来の配置ヒントを受け、env.GAME_HOST.get(id, { locationHint }) に渡す。無指定・不正値は現行の開催実態に合わせて apac に倒す。
パブリッシュの全体フロー
- ビルド時に全 import を解決: logic.ts が他モジュールを import していても、esbuild で単一ファイルに解決
- publish 時のサーバー側デプロイは無い: プレイ要求時に Worker Loader が R2 の logic.js + 現行テンプレをロードする
- テンプレ修正は再 publish 不要で全ゲームに伝播: 後述の TEMPLATE_VERSION による codeId 切替
uzuhq-cli コマンド
# どちらの方式でも同じコマンド
uzu publishuzu publish は manifest.json の serverActionLogicPath の有無でデプロイ内容を切り替える:
| manifest | uzu publish の動作 |
|---|---|
serverActionLogicPath なし | クライアント zip → R2 |
serverActionLogicPath あり | クライアント zip → R2 + logic.js を R2 へ (自動) |
serverActionLogicPath 指定時の logic アップロードの内部動作:
serverActionLogicPathで指定されたエントリポイント → esbuild → logic.js (全 import を解決した単一ファイル)- logic.js を R2 にアップロード (
{revisionId}/logic.js)
cli は「ビルド + アップロード + 登録」だけ
ランタイムテンプレ(GameFacet エンジン)の配布は uzu-code play-server 側の責務。cli はランタイムの配布チャネルではなく、ゲーム固有 logic.js のビルド・アップロードと revision 登録だけを担う。
ランタイムテンプレの所有とバージョン管理
ランタイムテンプレ(GameFacet エンジン = index.js / game-facet.js)は uzu-code play-server が所有する(src/game-worker-template/)。
エンジンの実体は 2 つの source-only パッケージに分かれており、build-template-sources.mjs がどちらも game-facet.js へバンドルして焼き込む。
| パッケージ | 中身 | 取り込む側 |
|---|---|---|
javascript/uzu_code/engine-core | ランタイム非依存な契約(GameLogic の型 / ゲーム内時計 / json-patch / seeded random / roster パース) | SDK (browser) / cli dev-server (Node) / play-server の facet (Workers) |
javascript/uzu_code/room-core | ゲームルーム本体(GameRoomCore)。サーバー専用 | cli dev-server (Node) / play-server の facet (Workers) |
分けてあるのは層が違うため。engine-core は作家と共有する契約で、sdk-server-stub の shim 経由でシナリオの logic.js からも実行時に到達できる。room-core はプラットフォーム内部の実装で、browser では動かず作家にも見せない。
テンプレには TEMPLATE_VERSION(src/template-version.ts)を持たせ、テンプレ変更時に必ず bump する。
- 接続時に codeId チェック: facet の codeId は
${revisionId}:${TEMPLATE_VERSION}。GameHost が storage に保存済みの codeId と照合し、不一致ならfacets.abort()→ 新 codeId でロードし直す - state を保ったままエンジン更新できる: abort は storage を消さない。進行中の部屋にもテンプレ修正が state 無傷で届く
- 再 publish 不要: テンプレ修正は uzu-code play-server のデプロイだけで、既存リビジョンを含む全 ServerAction ゲームへ伝播する。社外開発者に再 publish を強制する必要がない
- cli バージョン ≠ ランタイムバージョン: ランタイムの正しさは TEMPLATE_VERSION が一元管理する
料金
Dynamic Workers: $0.002/個/日(その日ロードされた revision のみ課金、月 1,000 個分は基本料金に含まれる)+ heartbeat 分の duration。旧 WfP 方式の購読 $25/月・script 数課金・cleanup 運用は存在しない。
Worker ルーティング
uzu-code play-server/src/index.ts が以下のルーティングで各 DO に振り分ける。
// uzu-code play-server/src/index.ts
GET /ws/games/{revisionId}/{roomId} → GameHost DO接続は idFromName(\${revisionId}😒{roomId}`)` で room ごとの GameHost を特定する。DO namespace は play-server worker 自身のものなので、環境 (dev / stg / prd) ごとに worker が分かれていれば名前衝突は起きない。
SDK 側で run() がこの 1 本に繋ぐ。init() だけを呼ぶ 1 人用シナリオは WebSocket を張らない。
ゲーム開始フロー
初期 state は facet が logic.setup() を実行して作る。プレイヤー一覧は最初の接続の URL パラメータ ?players=[...] で所与として渡される。facet はプレイヤー一覧が確定した時点で即座に logic.setup() を実行してゲームを開始する(接続数のカウントは行わない)。
再接続フロー
サーバーが state を持つので、再接続時にクライアントから state を要求する必要はない。GameHost が接続時に facet の現在の state を自動で送信する。
facet が再起動していた場合は、メモリ上の state が失われているため、hydrate() で storage から復元してから state を送信する。再接続かどうかは同一 playerId の接続数で判定する。
エラーハンドリング
不正な action の拒否
facet は受信した action を検証し、不正な場合は toSender でエラーを返す(送信者のみ)。
- 存在しないアクション名 →
__action_error。state は変更しない - reducer 実行時の例外 → state をスナップショットへ巻き戻して
__action_error。broadcast しない(「全部成功か全部失敗か」) - ゲーム未開始時の action →
__action_error
tick ループ中のエラー Recovery
update() でエラーが発生した場合、facet はログして次の tick に進む(該当 tick の broadcast はスキップ)。supervisor の tick ループは止めない。
締切 (deadlines) のエラー
締切 handler の例外はその締切ぶんだけ state を巻き戻し、他の締切を巻き込まない。alarm は at-least-once でリトライされるため、ゲームロジック起因の決定的バグを素通しするとクラッシュを繰り返す。facet 側で握ってログに残す。
エラーメッセージプロトコル
// Action エラー(送信者のみに返す)
{
"type": "__action_error",
"error": "Unknown action: invalidAction",
"seq": 42
}| エラー種別 | 対象 | 動作 |
|---|---|---|
| 存在しないアクション名 | 送信者のみ | __action_error を返す。state は変更しない |
| reducer 実行時の例外 | 送信者のみ | __action_error を返す。state は変更しない |
| tick 中の例外 | なし(サーバーログ) | 該当 tick の broadcast をスキップ |
| ゲーム未開始時の action | 送信者のみ | __action_error を返す |
SDK 内部の動作
クライアント予測 + Reconciliation
actions と serverActions の分離
actions はクライアント先読みとサーバーの両方で走るため決定的でなければならない。tick / 実時刻 / 乱数のように先読みで再現できない処理は serverActions に置く。同名にすると 1 つの action を「即座に反映していい部分」と「サーバーが決める部分」に分割できる(将棋なら盤面の 移動は先読み、持ち時間の減算はサーバー)。
型を分けているのは、両者の ctx が違うため。actions: Record<string, ActionHandler<S> | ServerOnlyAction<S>> のようなユニオンにすると、TypeScript が contextual typing を諦めて handler の引数がすべて implicit any になる(シナリオ側が全 handler に型注釈を書く羽目になる)。
旧 serverOnly() の互換
R2 に保存済みの古い logic.js は serverOnly() の brand を付けたまま actions に入っている。 新しいテンプレと組み合わさるため、サーバー側は brand 判定を残す必要がある(brand 付きの actions エントリは serverActions と同じ扱いにする)。全リビジョンが再 publish されるまで 消せない。クライアント SDK 側からは削除済み。
連続入力 (playerInputs)
WebSocket メッセージプロトコル
Client → Server
// 離散入力 (全て順序通り届く)
{ "type": "__action", "action": "changeDir", "payload": { "dir": "left" }, "seq": 42 }
// 連続入力 (最新値で上書き、最大 20Hz)
{ "type": "__input", "data": { "steer": 0.72, "throttle": 0.9, "brake": false } }
// アプリからの伝言 (SDK は中身を見ずに運ぶ)。通話の申告・緊急一時停止・解除
{ "type": "__platform", "message": { "type": "voice.report", "payload": { "session": "connected", "room": null } } }
{ "type": "__platform", "message": { "type": "pause.request", "payload": {} } }
{ "type": "__platform", "message": { "type": "pause.resume", "payload": {} } }
// wire 2 の SDK の緊急一時停止 / 解除 (引数なし・冪等)
{ "type": "__pause" }
{ "type": "__resume" }Server → Client
// Tick 更新 (定期)
{
"type": "__tick",
"state": { "snakes": {}, "foods": [] },
"events": [{ "name": "sound", "data": { "sound": "eat" } }],
"tick": 150
}
// Action 適用結果 (即時)
{
"type": "__action_result",
"state": { "snakes": {}, "foods": [] },
"events": [],
"serverEvents": [],
"ack": 42
}
// ゲーム開始
{ "type": "__game_start", "state": {}, "players": [], "seed": 1234567890, "gameTime": 0 }
// 緊急一時停止の状態 (全接続へ)。frozen は止まった理由を問わない
{ "type": "__pause_state", "frozen": true, "by": "seat_1", "gameTime": 812340 }
// 接続と通話の状態 (全接続へ・接続した端末へ)
{ "type": "__platform", "message": { "type": "presence", "payload": { "serverNow": 1790000000000, "seats": {}, "observers": { "count": 0 }, "pause": null } } }__platform の中身は 接続と通話の状態。wire 2 の SDK は知らない type として捨てる。
__tick / __action_result / __state はいずれも gameTime (ゲーム内時刻) を載せる。クライアントはこれでクロック オフセットを合わせる。Unix epoch ではないので Date.now() とは引き算できない。
State 永続化
ゲーム中の state 操作は 100% メモリ上 (μs オーダー)。 SQLite は「保険」として非同期バックグラウンド保存。facet の SQLite は play-server と同寿命で、コード更新 (facets.abort()) をまたいで生存する。
レイテンシ
全クライアントが DO を経由する同じ経路を通るので、席による有利不利が出ない (国内エッジで ~10-40ms)。supervisor→facet の RPC は同一 DO 内の isolate 間 hop で ~0.17ms なので、この数字には現れない。
ローカル開発方法
SDK のルーティング
SDK の run() は URL パラメータで実行モードを自動切替する:
| 条件 | roomId | server | 実行モード | 用途 |
|---|---|---|---|---|
| ソロ開発 | なし | - | runLocalServerAction | vite dev でブラウザ単体、 1 player 生 debug |
| マルチ dev | あり | あり | runOnlineServerAction | uzu dev の Node ws / Flutter Emulator + wrangler |
| 本番 | あり | あり | runOnlineServerAction | Cloudflare GameHost DO に接続 |
| 無効 | あり | なし | throw | multiplayer には必ず WebSocket server が要る |
ソロモード(roomId なし)
URL に ?roomId= がない場合、SDK は WebSocket 接続せずブラウザ内で全てを実行する。
# vite dev だけで動く
cd example_scenarios/shogi && pnpm exec vite dev
# → http://localhost:5173/ にアクセスソロモードは DO の挙動をブラウザ内で再現する。tickRate の既定は DO と同じ 0 で、event は 収集してから一括配信し、action / tick の例外は catch してログに残し次の tick へ進む。
マルチプレイ dev モード (uzu dev / Flutter Emulator)
server= を必ず付ける前提。 2 通りの立ち上げ方がある。
方式 A: uzu dev (@uzuhq/code-cli) が Node ws で in-memory GameRoom を提供する。 iframe grid + HUD + __uzu_dev admin channel も CLI がまとめて serve する。 wrangler も Flutter Emulator も不要。
cd example_scenarios/shogi && pnpm run harness
# → harness page http://localhost:<free>/ (iframe grid + Node ws server)
# → 子 iframe は ?server=ws://localhost:<port>&roomId=<key>&seatId=dev_N&players=[...]&seatKind=player&revisionId=dev方式 B: Flutter Emulator + wrangler dev で uzu-code play-server を起動 → 本番と同じ Cloudflare Worker runtime。ローカルでは Worker Loader / R2 の代わりに、シナリオの logic.ts を束ねた GameFacet を通常 DO として直バインドし、GameHost がそれを facet 代わりに使う(wrangler.dev.toml + src/dev-with-game.ts)。supervisor↔facet の RPC 経路は本番と同一。
task uzu_code:dev SCENARIO=shogi
# → zellij で SDK watch / wrangler / vite / Flutter Emulator を並走
# → Flutter Emulator が iframe 群を作り、 各 iframe に ?server=ws://localhost:$PS3_PLAY_SERVER_PORT を付与いずれの方式も iframe は ?server= + ?roomId= を持つので、 SDK は runOnlineServerAction (本番と同一 WS プロトコル) に流れる。
オンライン (本番) 接続
本番の Cloudflare GameHost に接続してテストする場合は、 server_url に prd を指定する。
flutter run -d macos \
--dart-define=endpoint=http://localhost:5173 \
--dart-define=server_url=wss://prd.uzu-code-play.uzu.one \
--dart-define=player_count=2WARNING
?roomId= があるのに ?server= がない iframe が着地すると、 SDK は Error: roomId is set but ?server= is missing を throw する。 multiplayer には必ず WebSocket server が要る。 ソロ debug したい場合は ?roomId= を落として素の vite dev で開くと runLocalServerAction に落ちる。