Skip to content

緊急停止 ​

内部ドキュメント

このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。

時刻 API の破壊的変更を含む

ctx.now(Unix ms)は廃止し、ゲーム開始からの経過 ms を表す専用型 GameTime に 置き換えてある(結論)。旧 revision は契約版の下限で 弾かれるので、緊急停止を入れる前に publish された成果物はプレイ開始前に止まる。

なぜ要るか — v2 の全数調査 ​

「そもそも用意しない」も選択肢だったので、v2 で実際どれだけ使われているかを調べた。 RTDB の日次フルダンプ(gs://uzu-prd-backups/、5.5 GB gzip)を全数走査した結果。

手法

ダンプは 5.5 GB の単一行 JSON。"pauseTime" を grep すると changeTextField に入った プレイヤーの入力文字列を誤カウントするので、文字列とエスケープを正しく無視する バイト単位の状態機械で走査した(1.97 億 operation / 4 分 10 秒)。 分母は BigQuery app_public から取った。

プレイ数停止を使ったプレイ率
マルチ(events、2023-06〜)605,553104,15417.2%
ソロ(solo_events、2024-02〜)235,59674,60731.7%
合計841,149178,76121.3%

5 回に 1 回のプレイで使われている。 総 pause 回数は 340,120 で、 直近 12 ヶ月も月 12,000〜14,000 回で横ばい。減っていない。

プレイが長いほど使われる ​

これが UZU Code にとって決定的になる。

プレイ時間プレイ数停止を使ったプレイ率
60 分未満273,79822,7928.3%
1〜2 時間210,45743,89420.9%
2〜3 時間65,28720,78931.8%
3〜4 時間17,8266,21234.8%
4 時間以上6,3442,82344.5%

シナリオ別では 4%(1分後に爆発します。)〜 37%(プロジェクト・ジェミニ)と幅があるが、 特定作品への偏りではなく尺の長さでほぼ説明できる。

uzu_code の本番コンテンツは 3 時間級の公演で、silk-and-chrome の logic には 「毎秒 update すると 3 時間の公演で 1 万回起きる」と書いてある。 v2 の同じ帯域では 35〜45% のプレイで停止が使われている。 用意しない選択肢は無い。

使われ方はマルチとソロで別物 ​

マルチソロ
停止時間の中央値42 秒2.8 分
1 分未満の割合57.9%39.7%
60 分以上の割合1.5%33.5%
解除されずに終了1,917 件48,378 件
使ったプレイでの平均回数2.2 回1.5 回
開始からの経過(中央値)46 分14 分

マルチは**「ちょっと待って」**。中央値 42 秒、6 割が 1 分未満。想像どおりの使われ方。

ソロは違う。pause 回数 110,131 に対して resume は 61,792 しかない。 差の 48,378 件は「止めたまま閉じた」で、60 分以上の停止が 33.5%。 ソロでは緊急停止が中断・保存として使われている。

自動解除タイマーを入れてはいけない

マルチの停止は中央値 42 秒だが、p99 は 220 分ある。 「N 分で自動解除」はこの尾を殺す。決定と論点 の 3 は「入れない」で確定する。

ソロは別機能として設計し直す価値がある

48,378 件の「止めたまま閉じた」は、緊急停止ではなく中断機能の需要。 全員切断で時計を止めるが入れば、ソロは「閉じれば止まる」で自然に満たせる。 UZU Code でソロに停止ボタンを出すかは、その挙動を入れてから判断する。

シナリオごとに実装させる選択肢も採らない。実装漏れがそのまま「この作品では止められない」になり、 止め方も見た目も作品ごとにバラバラになる。5 回に 1 回使われる機能を、 外部クリエイター 15 組に個別実装させる形にはできない。

用語 — 時計は 3 種類ある ​

以下、この 3 つを区別して書く。

巻き戻るかカレンダーの意味止まるか取り方
実時刻(wall clock)巻き戻る(NTP / 手動変更 / サマータイム / うるう秒)ある止まらないDate.now()
単調時計(monotonic)巻き戻らないない(起点は任意)止まらないperformance.now()
ゲーム内時計(本ページで新設)巻き戻らないない止まるctx.time

長さ(「あと何分」)の計算に実時刻を使うと、端末の時計がずれた瞬間に結果が飛ぶ。 カレンダーの判断(「12 月 25 日なら」)ができるのは実時刻だけ。 用途で分けて、混ざらないようにするのが POSIX / Rust / Go に共通する解き方で、 このページの結論もそこへ寄せる。

UZU Code は時計が 3 本ある ​

uzu_code には時間の源が 3 つあり、止まり方が揃っていない。

時間源進めているもの何もしなければ停止中どうなるか
ctx.tick(update / serverActions)GameHost の setInterval → facet の tickCount止まる。ループを止めれば tickCount は凍る
ctx.now(actions / serverActions / deadlines / setup)Date.now()進む
deadlines[].at() が返す絶対時刻state に書かれた Date.now() 由来の値進む。停止中に期限を通り過ぎ、解除直後に一斉発火する
クライアントの serverNow()サーバー打刻 + ローカル時計進む。カウントダウン表示だけが走る

example_scenarios/ の shogi は tick 基準で、tick ループを止めれば持ち時間も勝手に凍る。 本番作品と同じ締切ベースの example は quick_draw だけなので、shogi だけ見ていると「tick を止めれば済む」と誤解する。

本番コンテンツは tick を使っていない

released revision を持つ 2 作品はどちらも tickRate: 0 で、時間はすべて deadlines + 絶対時刻で動いている。

作品tickRate時間の持ち方
kikaidochu0deadlines 5 本。state.phaseEndsAt / presenterEndsAt / pendingDelayedLight.dueAt を ctx.now + 尺 で置く
silk-and-chrome未指定(= 0)deadlines 2 本。state.game.timerEndsAt と配布満期。「毎秒 update すると 3 時間の公演で 1 万回起きる」と明記して tick を捨てている

クライアント側も Math.ceil((endsAt - serverNow()) / 1000) でカウントダウンを描いている (kikaidochu/src/templates/presentation.ts)。

tick ループの停止は本番コンテンツに対してほとんど効かない。ゲーム内時計のほうが本体で、tick 停止はおまけ。

これは事故ではなく設計どおりで、engine-core の doc コメント自身が 「時刻をきっかけに何かを起こすなら tickRate で毎秒ポーリングせずこちらを使う」と deadlines を勧めている (ポーリング中は Durable Object が hibernate できない)。作家は勧められたとおりに書いている。

エンジンは state の時刻を書き換えられない

シナリオの state は不透明な JSON で、どのフィールドが時刻かをエンジンは知らない。 state.timer.endsAt を停止時間ぶん後ろへずらす、という直し方は原理的に取れない。

state を触るのではなく、state に書き込まれる時刻の出どころ(サーバーの時計)を止める。

どの層で持つか ​

層できることできないこと
シナリオ(logic.ts)何でも全作品に実装義務。漏れた作品は止まらない。UI も止め方も作品ごとにバラつく
エンジン(play-server + SDK)tick / 締切 / 時計 / action を原理的に凍結できる。シナリオ改修ゼロ画面を出せない(WebView の中なので、シナリオが壊れていれば出ない)
アプリ(Flutter)全作品共通の UI。WebView が壊れていても被せられる時間を止められない。サーバーは tick を回し続ける

結論は 3 層の分担になる。

  • UI はアプリ — overlay と発火 UI は packages/uzu_code(GamePlayScreen)に置く。mobile / UZU TOKYO / emulator が同じ絵になる
  • 凍結は play-server — ゲーム内時計・tick ループ・締切・action ゲートを持つ
  • シナリオは不参加 — 停止を知らないし、知る必要が無い

不変条件: 停止中、シナリオの state を変えるコードは 1 本も走らない

  • update() は呼ばれない(tick ループが止まる)
  • action は facet が弾く(SDK も先読みしない)
  • 締切 handler は発火しない(ゲーム内時刻が進まないので期限が来ない)
  • setup() は停止中に走りえない(開始前の部屋は停止できない)

唯一走るのは deadlines[].at()(state を読むだけの純関数)で、state は変わらない。 シナリオに停止を知らせないことが仕様であって、手抜きではない。

コアモデル — ゲーム内時計 ​

facet がスカラー 3 本を持ち、すべての時刻をそこから配る。

ts
// facet 内
let gameStartedAtWall = 0;               // setup() を実行した実時刻
let pausedTotalMs = 0;                   // 完了した停止の累計 (ms)
let pausedAtWall: number | null = null;  // 進行中の停止の開始実時刻。null なら停止していない

/** ゲーム開始からの ms。停止中は進まない。Unix epoch ではない。 */
const gameTime = (): GameTime =>
  ((pausedAtWall ?? Date.now()) - gameStartedAtWall - pausedTotalMs) as GameTime;

解除時に pausedTotalMs += Date.now() - pausedAtWall して pausedAtWall = null にする。 setup() の時点で必ず 0、停止をまたいでも連続、単調非減少。

v2 の getElapsed と同じ発想で、Elapsed という専用型を持っていたところまで同じ。 v2 は operation ログから毎回 elapsed を再計算するため Map<Elapsed, Duration> が要るが、 UZU Code の facet は state を持ち続けて replay しないのでスカラー 3 本で足りる。

置き換えるところ ​

場所現状変更後
ActionContext / ServerActionContext の nowDate.now()(Unix ms)time: GameTime + after(d)
UpdateContext の nowDate.now()同上
DeadlineContext の nowDate.now()同上
SetupContext の nowDate.now()同上(必ず 0)
Deadline.at() の戻り値number | nullGameTime | null
broadcastStateDelta の serverTimeDate.now()gameTime フィールドへ改名
__state の serverTimeDate.now()同上
nextDeadline() の戻り値ゲーム内時刻をそのまま返す実時刻へ直して返す(下記)
seed = Date.now()—変えない。時刻ではなく ID として使っているだけ

同じ置き換えを sdk/src/run/local-server-action.ts(ソロ)と cli/src/dev-server/game-room.ts(dev harness)にも入れる。 3 実装が別々に時計を持つので、ここがズレると「dev では止まるのに本番で止まらない」が生まれる。

締切の予約 ​

nextDeadline() はシナリオのゲーム内時刻を返すが、GameHost の alarm は実時刻で動く。変換は facet 側で閉じる。

ts
// 停止中は締切を予約しない (heartbeat だけが残る)
const wallDeadline = frozen ? null : at + gameStartedAtWall + pausedTotalMs;

こうすれば GameHost はゲーム内時計を一切知らずに済み、FacetOutcome.nextDeadline の意味(実時刻)も変わらない。

停止に入った時点で予約済みの alarm はそのまま残り、停止中に発火する。 facet はゲーム内時刻で期限を見るので空振りし、再アームで自己修復する。 これは syncAlarm の「現在の alarm より早い時だけ set し、後ろ倒しはしない」規則が既に想定している経路で、変更は要らない。

tick ループ ​

FacetOutcome に frozen: boolean を足し、GameHost は

  • frozen === true → stopTickLoop()
  • frozen === false に戻ったら既存条件(started && tickRate > 0 && sockets > 0)で再開

とする。

永続化 ​

gameStartedAtWall / pausedTotalMs / pausedAtWall / frozenBy を facet storage に置く。 停止の遷移だけは state の遅延書き込み(最大 500ms)を通さず即時 put する。 停止直後に evict されて停止が消えると、世界が勝手に動き出す。

実時刻は作家に見せない ​

serverNow() は何を返すか ​

今は実時刻を返す。observeServerTime() がサーバー打刻との差を offset に持ち、Date.now() + offset を返している。 名前のとおり「サーバーの実時刻の推定値」で、ゲーム内時計ではない。

gameTime(): GameTime に置き換える。返すのはゲーム開始からの経過 ms で、停止中は凍る。 基準も Date.now() から performance.now() へ変える(後述)。

旧 SDK は救えない

epoch が変わるので、焼き込み済みの旧 SDK が読む serverTime は桁ごと意味を失う。 endsAt - serverNow() は無意味な値になり、カウントダウンが壊れる。

旧 revision は門前で弾く。 WIRE_VERSION の下限を上げて、範囲外の作品は 既存の口(IsPlayable() → UPDATE_REQUIRED)でプレイ開始前に止める。 中途半端に繋いで壊れた画面を見せるより良い。

「実時刻を取れなくする」はどこまでできるか ​

型で分けても、作家が Date.now() を直接呼べば実時刻は手に入る。ただし epoch が違うので 混ぜた瞬間に桁で分かる(締切が永久に来ない / カウントダウンが 2 万日になる)。 そのうえで、値そのものを塞げる度合いは層で違う。

層塞げるか手段
logic.js の同期ハンドラ(setup / actions / update / deadlines[].handler)塞げる呼び出しの前後で Date.now / Date / performance.now を差し替える
logic.js の serverActions(async)塞げないawait をまたぐと復元が効かない。同じ isolate に別部屋が同居しうるので、恒久パッチにも倒せない
deadlines[].at()塞がない「state だけから決まる軽い純関数」という既存の契約があり、ここで時刻を読むのは元から誤り
ブラウザ(main.ts / 描画)塞げないperformance.now() / rAF の timestamp / Intl / third-party ライブラリが残る。塞いだつもりが一番危ない

同期ハンドラの差し替えは sdk/src/dev-prediction-traps.ts の runPredicted() と同じ形になる。 あちらは dev で警告を出すために差し替えているが、こちらは本番でゲーム内時計を返すように差し替える。 await を挟まないので、JS が単一スレッドである以上ほかの部屋と混ざらない。

ただし epoch が違う以上、生 Date.now() を state へ入れているコードは差し替えても意味が合わない (tenbo-danwashitsu の 6 箇所が該当する)。差し替えは保険であって、移行の代わりにはならない。

ブラウザ側は塞げないぶん、dev-prediction-traps の罠を actions の先読みだけでなく描画経路へも広げて警告する。 本番では素通しする、という現行の方針は変えない。

時刻の形 — 業界の 4 系統に照らす ​

「時刻を Date の形で渡すこと自体がミスリードではないか」という疑いは正しい。 業界を見ると、この手の時計は必ず何かで守られている。

系統代表時刻の形タイマーの持ち主守り
ゲームエンジンUnity / Unreal / Godot開始からの経過秒(Time.time / UWorld::GetTimeSeconds)エンジン(world と一緒に止まる)形 + 持ち主
ゲームサーバーColyseus両方読める(clock.currentTime / clock.elapsedTime)エンジン(Delayed.pause())持ち主
Durable executionTemporal / Azure Durable Functions実時刻の形。sandbox が Date / random / setTimeout を差し替えるエンジン(sleep(duration))持ち主
システムプログラミングPOSIX / Rust / Go単調時計と実時刻は別の型。Rust の Instant は不透明で、差を取って Duration にしかできない呼び出し側型

uzu_code はどこにも属していない

時刻の形は実時刻、タイマーの持ち主は作家の state。守りが 1 つも無い唯一の組み合わせにいる。

Date.now() と ctx.now が同じ number で、桁も同じで、置き場所も同じ state になる。 取り違えても型でもレビューでも実行時でも気付けない。

Temporal の pause は真似できない

Temporal の Workflow Pause は「新しい task を dispatch しない」だけで、durable timer は実時間で経過し続ける。 解除すると溜まった分が一斉に発火する。あちらのタイマーは SLA のような現実世界の締切なので、それが正しい。

uzu_code のタイマーは劇中の残り時間なので、真似すると壊れる。 ここはゲームエンジン側(world ごと止める)の答えを採る。

結論 — 経過 ms + 専用型に寄せる ​

リリース前で破壊的変更が許されるなら、実時刻の形を捨てるのが業界標準に対する正解になる。

  • ゲーム内時計はゲーム開始からの経過 ms。Unix epoch ではない
  • 型で実時刻と分ける。Date.now() はゲーム内時刻の場所に代入できない
  • タイマーの持ち主は変えない(後述)
ts
// @uzuhq/code-engine-core
type GameTimeTag = { readonly [gameTimeBrand]: 'GameTime' };
/** ゲーム内時刻。開始からの ms。停止中は進まない。Unix epoch ではない。 */
export type GameTime = (number & GameTimeTag) | GameTimeTag;
/** 長さ (ms)。 */
export type Duration = number;

export const plus  = (t: GameTime, d: Duration): GameTime => (toMs(t) + d) as GameTime;
export const sub   = (t: GameTime, d: Duration): GameTime => (toMs(t) - d) as GameTime;
export const minus = (a: GameTime, b: GameTime): Duration => toMs(a) - toMs(b);

/** ゲーム内時計の原点。`setup()` の `ctx.time` と同じ値。 */
export const GAME_START: GameTime = asGameTime(0);
// toMs はエンジン内部専用で、パッケージの公開 API には出さない。
// 公開すると toMs(a) - toMs(b) と書けて保護が消えるため。

経過 ms が要るときは minus(t, GAME_START)

表示やログのために素の数値が欲しくなる場面はある。toMs を公開しない代わりに、 原点そのものを GameTime として出してある。

ts
const elapsedMs = minus(at, GAME_START);   // ✅ cast 不要

GAME_START は toMs のような抜け道にはならない。差を取るには結局 minus を通るので、 実時刻との取り違えは型で止まったままになる。

これが無いと各シナリオが 0 as GameTime を別々の名前で再発明する (実際 silk-and-chrome と kikaidochu で 4 箇所に増えた)。

number & Tag では読み側が守れない

交差だけにすると GameTime が number へ代入できてしまい、この設計が潰そうとしている 当のバグが型検査を通る。

ts
state.endsAt = Date.now() + 尺;                                  // ❌ 交差でも止まる (書き込み)
Math.max(0, Math.ceil((state.endsAt - Date.now()) / 1000));      // ⚠️ 交差だと通る (読み)

しかも実行時に静かに壊れる。endsAt が 3e5 のところへ Date.now() の 1.79e12 を引くと 巨大な負数になり、カウントダウンの慣用句である Math.max(0, …) に潰されて タイマーが 00:00 のまま固まる。例外もログも出ない。

(number & Tag) | Tag にすると number への代入と算術が型で止まり、GameTime 同士の 比較・===・テンプレート・Number.isFinite・JSON 往復・一段 cast はそのまま使える。 TypeScript コンパイラ自身が __String (識別子の内部表現) で同じ形を採っている (microsoft/TypeScript#16915)。

GameTime の実体はただの number

実行時は数値そのもの。typeof ctx.time === 'number' が true で、JSON.stringify すると 300000 になる。 ランタイムコストも wire への影響もゼロで、state は今までどおり素の JSON のまま。

型が効くのはコンパイル時だけで、しかも片方向。

ts
const t: GameTime = ctx.time;          // ✅
const d: number   = minus(a, b);       // ✅ 差は Duration (number)

const x: GameTime = Date.now();        // ❌ number → GameTime は通らない
const y: GameTime = ctx.time + 60_000; // ❌ GameTime に算術は使えない
const z: number   = ctx.time;          // ❌ number への暗黙の代入も通らない
const w: number   = a - Date.now();    // ❌ 実時刻との引き算が型で止まる

最後の 1 行が ctx.after() を用意する理由になる。算術をすると brand が落ちるので、 GameTime を作る道は engine-core が as GameTime を内側に閉じ込めた関数 (ctx.after / plus)しか無くなる。

作家が自分で as GameTime と書けば破れる。サンドボックスではなくガードレールで、 Rust の unsafe と同じ位置づけ。破るには意図が要る、というところまでを担保する。

クラスでラップしない

class GameTime { constructor(readonly ms: number) } にすれば実行時にも型が残るが、採らない。

uzu_code の state は素の JSON でなければならない。structuredClone で snapshot を取り、 json-patch で差分を作り、SQLite へ JSON.stringify して永続化する。 クラスを混ぜると 3 つとも壊れ、復元のたびに revive 処理が要る。値ごとにアロケーションも走る。

brand なら「実行時は number、型の上だけ別物」が成立するので、この 3 つに一切触らずに済む。

TypeScript を書いている作家にしか効かない

JS で書かれたシナリオには型検査が無い。ただしその場合も epoch の違いが残るので、 Date.now() を混ぜれば締切が 56,000 日後になって初回で気付く。型は 1 段目、epoch は 2 段目。

旧新型
ctx.now(Unix ms)ctx.timeGameTime
—ctx.after(d)GameTime
SetupContext.nowctx.time(必ず 0)/ ctx.afterGameTime
Deadline.at(): number | nullat(): GameTime | null強制点
クライアントの serverNow()gameTime()GameTime
wire の serverTimegameTime—
ctx.tick据え置きnumber

at() の戻り値の型が全部を駆動する

at(): GameTime | null にすると、作家は Date.now() + 尺 も素の number も返せなくなる。 値を得る道が ctx.after() / plus() しか無くなり、state のフィールドに GameTime と書く動機が自動的に生まれる。

.d.ts の 1 行が全作品に規律を伝播する。ブランドは GameTime → number へは代入できるので、 受け取って計算するだけのヘルパー((now: number) => ...)は書き換えなくてよい。

名前は ctx.time にする

ctx.now を残して意味だけ変えるのは最悪の選択になる。既存コードがコンパイルを通ったまま 別の epoch で動き、しかも誰も気付かない。改名して落とす。

ctx.elapsed も候補だが ctx.time を採る。型名 GameTime と対になること、 Unity の Time.time がゲーム開発者にとって最も通りの良い前例であること、 そして「実時刻ではない」と叫ぶ役目は名前ではなく型と epochが負うべきだから。 名前に安全性を背負わせると、名前を変えた瞬間に安全性が消える。

actions にも時刻を渡し続ける

engine-core の doc は「クライアントが再現できない値を actions に配るな」と書いている。 時刻は厳密には再現できない(先読みは推定値)が、渡し続ける。

止めると state.phaseEndsAt = ctx.after(5 * 60_000) が serverActions 送りになり、 カウントダウンの表示が往復ぶん遅れて出る。実際 kikaidochu は先読みとサーバー確定の差を TIMER_CORRECTION_TOLERANCE_MS = 500 で吸収する作りになっていて、この設計で回っている。

推定値であることは ActionContext.time の doc コメントに明記し、 時刻での分岐(「期限を過ぎていたら」の判定)だけを update() / deadlines 側に寄せる。

サーバー側の実体はスカラー 3 本になる。

ts
gameStartedAtWall: number;        // setup() を実行した実時刻
pausedTotalMs: number;            // 完了した停止の累計
pausedAtWall: number | null;      // 進行中の停止の開始実時刻

const gameTime = (): GameTime =>
  ((pausedAtWall ?? Date.now()) - gameStartedAtWall - pausedTotalMs) as GameTime;

setup() の時点で必ず 0。単調非減少。停止中は凍る。 alarm へ渡す実時刻は at + gameStartedAtWall + pausedTotalMs(停止中は予約しない)。

ついでに直す — クライアントの補間は monotonic に ​

server-clock.ts は今 Date.now() + offset で補間している。 実時刻を長さの計算に使うのは POSIX / Rust / C++ が揃って禁じている形で、 端末の時計が NTP やユーザー操作でずれると、その瞬間に全部のカウントダウンが飛ぶ。

performance.now()(monotonic)を基準に張り替える。

ts
// gameTime を受けるたび
offset = serverGameTime - performance.now();
// 読むとき
export const gameTime = (): GameTime =>
  (frozenAt ?? (performance.now() + offset)) as GameTime;

これは緊急停止とは独立のバグ修正だが、同じ破壊的変更でまとめて入れる。

タイマーの持ち主は変えない ​

右 2 系統(Temporal / Colyseus / Unreal)は「持ち主」で守っている。uzu_code でやるなら ctx.timer.start('phase', 60_000) になるが、採らない。

state 由来の締切には、あちらが持っていない利点が 2 つある。

  1. rollback がタダ — action が throw して state が巻き戻れば、締切も一緒に巻き戻る
  2. 再起動をまたいで生き残るのがタダ — 締切は永続化済みの state から導出されるので、 予約表という「2 つ目の永続物」を state と整合させ続ける必要が無い

Colyseus の Delayed はプロセスが死ねば消える。Temporal のタイマーは生き残るが、 そのために event sourcing のエンジンを丸ごと抱えている。 この一点では uzu_code の設計のほうが良い。 直すのは時刻の値であって、締切の持ち方ではない。

移行コスト(実測) ​

ctx.now / Date.now() / serverNow() を使っているシナリオは 4 本しかない。

repoctx.nowlogic の Date.now()serverNow()備考
silk-and-chrome850(テストに 11)5最大。ほぼ機械的置換
kikaidochu21106
tenbo-danwashitsu06(+ server-port.ts に 20)0ctx.now を使わず生 Date.now() を state に入れている
coastal-council010

残り 11 本の Date.now() は描画用(nostalgia/src/main.ts の 60 箇所など)で、state に入らないので無関係。

約 130 箇所。しかも全部 tsc が指す。 ctx.now を消して ctx.time にすれば、 残った参照はコンパイルエラーになって現れる。黙って別の epoch で動き続ける危険が無い。

今やらないなら二度とできない

epoch を変える窓は「ctx.now の利用者が数えられるうち」だけ。 released 済みの作品が増え、外部クリエイターが増えたあとでは、 互換性の制約で、Unix epoch のまま止まる時計を被せる以外に手が無くなる。

ctx.wallNow は足さない

サーバーで本物の実時刻が要る用途は今のところ無い(デバイス連携の fetch も署名を持たない)。 必要になってから足す。先に用意すると「とりあえずこっち」で使われる。

停止は boolean ではなく理由の集合で持つ

paused: boolean ではなく frozenBy: Set<'emergency-stop' | 'no-players'> にして、 集合が空のときだけ時計が進む形にする。 全員切断中も止めるを後から足すときに、もう一度破壊的変更をしないで済む。

Unity / Unreal の timeScale のような連続値にはしない。rate が 0 / 1 以外だと 締切の実時刻を毎回引き直す必要があり、今それに見合う用途が無い。

プロトコル ​

Wire(SDK ↔ play-server) ​

向きメッセージ中身
client → server{ type: '__platform', message: { type: 'pause.request', payload: {} } }停止の要求。冪等(停止中なら no-op)
client → server{ type: '__platform', message: { type: 'pause.resume', payload: {} } }解除の要求。冪等
client → server{ type: '__pause' } / { type: '__resume' }wire 2 の SDK が送る同じ要求。サーバーは両方を受ける
server → 全接続{ type: '__pause_state', frozen, by, gameTime }時計を止める合図。frozen は止まった理由を問わない。by は停止を要求した playerId。gameTime は打刻(停止中は凍った値になるので、停止に入った時刻でもある)
server → 全接続__platform の presence の pause止まった理由({ reason: 'emergency', by })。接続と通話の状態
server → 接続者__state に frozen / pausedBy を追加停止中に接続・再接続した端末が状態を得るため
server → 送信者__action_error の error: 'Paused'停止中に届いた action の拒否

停止の要求は action ではないので、シナリオの actions 名前空間とは衝突しない。 サーバーは roster にいない席と、監視で開いた接続(?monitor=1)からの要求を無視する。

切断中の要求は再接続後に届く

ReconnectableWebSocket は切断中の送信をバッファして再接続時に流す。停止の要求も その対象なので、押した直後に切れた場合は楽観表示のタイムアウト(5 秒)で失敗を伝えたあとに サーバーへ届くことがある。落とさないのは、押した理由(急用・トラブル)が消えるわけでは ないから。遅れて掛かった停止は overlay からプレイヤーなら誰でも解除できる。

Bridge(SDK ↔ Flutter) ​

向きchanneltypepayload
ホスト → ゲームplatformpause.request{}
ホスト → ゲームplatformpause.resume{}
ホスト → ゲームsdkrequestPause / requestResume{}(bridge 1 の SDK 向け)
ゲーム → ホストsdkpauseState{ paused, by }

ホストは新旧どちらの SDK の作品も止められるよう、platform と sdk の両方へ送る。SDK は知らない方を捨て、サーバーの停止は冪等なので二重には止まらない。 platform の伝言は SDK が中身を見ずにサーバーへ運ぶ。roomId なしのローカル実行ではサーバーが無いので、SDK が自分で止める。

SDK の公開 API ​

ts
export function getPause(): Pause;
export function onPauseChange(cb: (pause: Pause) => void): void;

type Pause =
  | null
  | { reason: 'emergency'; by: SeatId }
  | { reason: 'waiting'; for: SeatId[]; since: number };

読み取り専用で、GameLogic には出さない。用途は演出の停止(描画ループを止める等)に限る。 停止を state に持ち込めるようにすると、上の不変条件が崩れる。

理由は 1 つだけ持つ。理由は今後増えることがあるので、読む側は知らない reason でも止まっているものとして扱う。SDK は時計と送信を、理由を見ずに wire の frozen だけで止める。理由が増えても、公開済みのシナリオの時計は止まる。

流れ ​

解除は逆向きに同じ形で流れる。

停止中の各層の振る舞い ​

層停止中
GameHosttick ループを止める。heartbeat は接続がある限り継続
facet__action を Paused で拒否。gameTime() が凍る。締切を予約しない
SDKsend() を no-op(先読みもしない)。gameTime() を凍結。getPause() が理由を返す
シナリオの描画ループonPauseChange で止められる(overlay で見えないが、電池と発熱のため)
Flutter全画面 overlay。WebView にタッチが届かない。HUD のアクションバー(マイク)だけは overlay の上に残し、UZU ボタンは隠す
音声全員を共通チャンネルへ。解除で元へ。voicePlan の locked のロックも外し(ミュートは切れたまま)、解除でまたロックする

クライアントの時計の合わせ方

サーバーは broadcast のたびに gameTime を打刻する。クライアントは offset = serverGameTime - performance.now() を持ち、performance.now() + offset を返す。

停止中は broadcast が止まって打刻が来ないので、SDK が __pause_state を受けた時点の値で明示的に凍らせる。 解除後は通常の打刻でオフセットが入れ替わるので、特別な補正は要らない。

アプリ UI ​

  • 発火 — mobile は UZU メニュー(adapters/uzu_code.dart の _showUzuMenu)に項目を足す。roster にいない席(観戦・進行管理)と、監視で開いたエミュレータ(monitor=1。UZU ADMIN のイベント詳細と Studio のテストプレイ履歴が付ける)には出さない(PlayScreenController.canControlPause)。v2 の文言をそのまま使う。メニューはホストが持つ UI なので、文言も各ホストの i18n に置く(overlay の文言だけが packages/uzu_code の i18n)

    緊急一時停止 — 全員のタイマーを一時的に停止します。急用やトラブルの際にご利用ください

  • overlay — packages/uzu_code 側に置き、GamePlayScreen が pauseState を受けて自分で被せる。ホストごとに実装しない

  • 解除 — overlay の「プレイ再開」。出すのは発火と同じく canControlPause の席だけ。v2 は overlay から即解除で、タイマー経由の解除にだけ確認ダイアログを挟んでいる。UZU Code は入口が 1 つなので確認ダイアログを挟む(誤タップで全員のプレイが再開してしまうため)

  • 楽観表示 — 押した端末は確定を待たずに overlay を出す。一定時間(5 秒)内に pauseState が返らなければ引っ込めて失敗を伝える

  • HUD — アクションバー(マイク等)は overlay より上に積み、停止中もミュートを切り替えられるようにする。ミュートはホストの音声設定でシナリオの state ではないので、停止中に state を変えない不変条件に触れない。UZU ボタンは停止中は隠す(メニュー経由の退出・設定・二重停止をさせない)。場所は確保したままにして、停止の前後でマイクの位置を動かさない

  • 中身 — 既定は roster のアバターと名前を並べる(PlayScreenController.players から出すので、ホストごとの実装が要らない)。誰が居て誰が話しているか分かる状態のまま相談できることが、この画面の実用上の価値。停止中は overlay がシナリオの話者表示を隠すが、通話はホストの持ち物でパッケージからは見えないので、通話のあるホストは GamePlayScreen.pauseRoster で自前の話者表示に差し替える。mobile は UZU メニューと同じ UzuCodeSpeakers(話者表示)を渡す。通話の無いホスト(UZU TOKYO・emulator)は既定のまま

音声(Agora) ​

v2 と同じ扱いにする。停止で全員を共通チャンネルへ、解除で元の部屋へ。

UZU Code はシナリオが決めた部屋(presence の plan。bridge 1 の作品は changeRoom)をアプリが requestedRoomIdRef に持っているので、復帰はその再適用でよい。 停止中に届いた部屋の変更は記録だけして適用しない(シナリオは凍っているので通常は届かないが、SDK のバッファ経由でありうる)。

voicePlan の locked のロックも停止中は外す。ミュートは切れたままにして、話す人は自分で戻す。解除すると、またロックしてミュートする。ロックを外すのはサーバーが停止を確定してからで、要求中(確定待ち)は外さない。

SE は初版では鳴らさない。v2 で用意した uzu_emergency_stop.mp3 が 「Agora の changeRoom 中は音声を再生できない」制約でコメントアウトされたままで、UZU Code も同じエンジン・同じ順序を踏むため。

BGM は止めない。AbstRTCEngine に pause が無く、stop → 再 play が頭出しになる。

ソロ / dev / emulator ​

経路対応
ソロ(runLocalServerAction)同じゲーム内時計をブラウザ内に持つ。tick の setInterval と締切の setTimeout を止め、解除で再アーム
dev harness(uzu dev)cli/src/dev-server/game-room.ts にも同じものを入れる
emulatorpackages/uzu_code の overlay がそのまま効く

__uzu_dev.pauseTick() と混同しない

dev hooks の pauseTick は既にあるが、別物。tick を止めるだけで、ゲーム内時計も action ゲートも持たない (doc コメントにも「pause 中も send と state 書換 API は通る」と書いてある)。 開発者が 1 tick ずつ進めるための道具で、緊急停止の実装に流用しない。

互換性 — 一度だけ壊す ​

リリース前なので、契約面のうち変わったものだけを上げて一度だけ破壊的に切り替える。 epoch を変える窓は今しかない(移行コスト)。

契約面変更対応
C1 Bridgebridge メッセージ 3 種を追加上げない(下記)
C2 WireserverTime → gameTime(意味が変わる)、停止メッセージ 3 種を追加WIRE_VERSION を上げ、下限も同時に上げる
C3 Logicctx.now 廃止 / at() の戻り値型変更ENGINE_VERSION は実体の hash なので自動で変わる。アダプタは書かない
C5 StoragegameStartedAtWall / pausedTotalMs / frozenBy を追加進行中の部屋は移行しない(下記)

C1 Bridge は上げない

一度 BRIDGE_VERSION を 2 へ上げたが、戻した。足しただけで、変えていないため。

  • 旧シナリオ × 新アプリ — 停止要求が無視され、5 秒でタイムアウトして戻る
  • 新シナリオ × 旧アプリ — pauseState が捨てられ、停止できないだけ

どちらの向きも degrade するだけで壊れない。上げる基準は「境界のメッセージや呼び出し規約を 変えたとき」であって、加算は該当しない。

しかも BRIDGE_VERSION はシナリオごとの能力宣言ではなくグローバルな定数なので、 上げると停止を使わないシナリオまで巻き添えで新版を名乗り、旧アプリから締め出される。 実際に上げた直後、既存の TestFlight ビルドが全ての新規 publish から弾かれた。

ゲーム内時刻 (ctx.time) は C2 Wire の管轄で、bridge は関与しない。旧シナリオが壊れた 時間で動くのは SupportedWireRange が止める。

アダプタを書かない、という判断

versioning のルールでは、logic 呼び出し規約を変えたら旧規約のアダプタをエンジンに残すのが定石になっている。 今回は書かない。

epoch の違いは引数の形の違いと違って、アダプタでは吸収できない。 旧 logic が state に持っている phaseEndsAt は Unix ms で、新エンジンの時計は経過 ms。 両者を突き合わせる正しい変換は「その部屋がいつ始まったか」を知らないと作れず、 旧 state にはその情報が無い。

下限を上げて弾くほうが正しい。アダプタを書けば、弾かれるはずの部屋が 壊れた時間で動き続けることになる。

進行中の部屋をどうするか ​

gameStartedAtWall を持たない state は移行できない。進行中の部屋は新エンジンで開かない。

released 済みの作品は公演単位で走っており、公演をまたいで部屋が残ることは無い。 切り替えは公演と公演の隙間に入れる。中断中の部屋が残っていたら、それは捨てる。

契約版を宣言していない revision も止める

IsPlayable() は「契約版の宣言が無ければ通す」という抜け道を持っていた。2026-08 時点で prd の uzu_code revision 7 本は全部が宣言を持っていない(uzuCodePublishMeta に cliVersion / sdkVersion しか入っていない)ため、下限を上げても 1 本も止まらなかった。

宣言が無いものは最古 (0) として同じ範囲判定に載せる形へ直してある (backend/internal/ent/0_uzu_code_version.go)。ここを「無条件で通す」に戻すと、 弾かれるはずの成果物が新しいエンジンで壊れた時間のまま動く。

やること ​

  1. WIRE_VERSION の下限を上げる(旧 revision は IsPlayable() で門前に止まる)。BRIDGE_VERSION は上げない
  2. シナリオ 4 本を再 publish(移行コスト)
  3. 公演の無い時間帯に play-server をデプロイ(ENGINE_VERSION は実体の hash なので自動で変わる)

決定と論点 ​

決まったこと ​

論点決定
発火 UI の置き場所プレイヤー端末の UZU ボタン(HUD 左上)のメニューに置く。プラットフォーム所有の UI なので、シナリオの外側で完結する
UZU TOKYO同じ UZU ボタン経路で対応する。スタッフ端末から止める専用の制御 API は作らない。play_room_uzu_code.dart に onMenuPressed を繋ぐだけ(未結線)。UZU TOKYO の端末はプレイヤーの席でつながるので、結線すればそのまま止められる
誰が止められるかroster にいるプレイヤーだけ。観戦・進行管理の席は roster に載らないまま接続してくるので、サーバー(room-core)は roster にいない席からの停止・解除の要求(pause.request / pause.resume、wire 2 の SDK は __pause / __resume)を無視する。ライブ配信では観戦者が最大 1000 人になり、その誰でも卓を止められる状態を塞ぐ。seatKind は自己申告で確かめようがないので判定に使わない。運営や作者が卓を止める場面も想定しない。UZU ADMIN のイベント詳細と Studio のテストプレイ履歴から監視で開いたエミュレータ(monitor=1)はプレイヤーと同じ席でつながるが、wire 3 の SDK が印を WebSocket の接続 URL へ運ぶので、サーバーはその要求も無視する。アプリも操作を出さない。wire 2 の SDK の作品では印がサーバーまで届かないので、アプリが操作を出さないことだけで防ぐ
無人凍結初版のスコープに入れる(設計)
デプロイ窓・再 publishリリース計画側の話として切り離す

無人凍結(no-players) ​

停止は boolean ではなく理由の集合で持つ。集合が空のときだけ時計が進む。

ts
frozenBy: Set<'emergency-stop' | 'no-players'>
理由付く条件外れる条件クライアントに見せるか
emergency-stop停止の要求(pause.request / __pause)を受けた解除の要求(pause.resume / __resume)を受けた見せる(overlay)
no-players接続 0 が猶予を超えて続いた誰かが接続した見せない

no-players を見せないのは、外れる条件が「誰かが接続した」なので、 クライアントが見られる状態では必ず外れているから。overlay は emergency-stop だけで出す。

数えるのは観戦者と、監視で開いた接続(monitor=1)も含めた全部の接続。 プレイヤーだけで数えると、観戦者が見ている部屋で止まるのに止まったことが配られず、 観戦者の画面だけ時計が進んで、プレイヤーが戻ったときに巻き戻る。

猶予なしで凍らせてはいけない

「接続が 0 になった瞬間に凍らせる」は素直だが壊れる。

マダミスの議論フェーズではプレイヤーが画面を見ずに喋る。端末を伏せると iOS は 30 秒ほどでアプリを suspend し、WebSocket が落ちる。全員が同時にそうなると、 議論が続いているのにフェーズタイマーが止まる。

実際には Agora の通話が background audio でアプリを生かすので発生しにくいが、 通話を切っている卓では起きうる。猶予を置く。

  • 猶予は 5 分(定数で持ち、調整できるようにする)
  • 猶予の経過は遡らない。凍結は猶予が切れた時点から始まる。短い切断は時計に影響しない
  • 猶予の満了は alarm で起こす。syncAlarm は「より早い時だけ set」なので候補を 1 つ足すだけで済む
  • 発火を必ずログに残す。どれくらいの頻度で凍っているかは後から測れないと困る

これを入れないと起きること

v2 は各クライアントが getElapsed を計算するだけなので、全員切断中は「誰も計算しない」で済んでいた。 UZU Code はサーバーが誰も居なくても締切 handler を実行する。

kikaidochu の phaseTimer / delayedLight は enqueueDeviceCommands を呼ぶので、 無人の部屋で UZU TOKYO の照明が切り替わりうる。

さらに v2 ソロの使われ方がこれで自然に満たせる。 v2 ソロの pause 110,131 回のうち 48,378 回は「止めたまま閉じた」= 中断で、 UZU Code のソロも play-server の部屋に繋がる(solo_scenario/uzu_code.dart が roomId と serverUrl を渡す)ため、 閉じれば止まるが成立する。

調査で閉じたもの ​

論点結論根拠
そもそも要るか要るv2 で 21.3% のプレイが使用。3〜4 時間帯では 34.8%、4 時間以上で 44.5%(全数調査)
自動解除タイマー入れないマルチの停止は中央値 42 秒だが p99 は 220 分。自動解除は尾を殺す
停止の記録入れるv2 は計測ゼロで、今回 5.5 GB のダンプを全数走査してようやく使用率が分かった
時刻の epoch と型GameTime(開始からの経過 ms)へ破壊的変更業界の 4 系統
タイマーの持ち主変えない(state 由来のまま)rollback と再起動生存がタダで付く
音声の共通ルーム移動初版から入れるchangeRoom は実際に使われている(silk-and-chrome 3 / tenbo-danwashitsu 4 / nostalgia 4 箇所)
UZU Code のソロの state 永続化問題なしソロも play-server の部屋に繋がるので facet storage に載る

着手前に潰しておくリスク ​

#リスク対処
1シナリオ 4 本が別リポジトリなので、1 本の PR に物理的に収まらない下記「PR の切り方」
2play-server のデプロイは稼働中の部屋を毎回再起動する(versioning)公演の無い時間帯にデプロイする。停止の遷移は即時 put なので、再起動で失うのは state 側の最大 500ms 分に留まる
3ctx.now を時刻でなく ID の材料に使っている箇所がある。kikaidochu の deviceSessionId: ctx.now.toString(36) は、epoch が部屋ごとの経過 ms になると部屋をまたいだ一意性を失う機械置換で見落とさない。ID 用途は ctx.random だけに寄せる
4全画面 overlay が WebView のタッチを本当に食うか。iOS の WKWebView は gesture を横取りすることがある実機で確認する。Flutter 側の Stack の上に不透明な Container を置く前提だが、机上では担保できない
5Workers の Date.now() は I/O 間で凍る(spectre 対策)。pausedTotalMs += Date.now() - pausedAtWall がミリ秒単位でずれうる影響は無視できる大きさだが、facet 内での実挙動は未確認
6同期ハンドラの Date.now 差し替えは、handler が throw しても必ず戻す必要があるtry / finally で囲む。deadlines[].at() は対象外にする(純関数契約)
7時計の算術・凍結・Date.now 差し替えが 3 実装に散ると必ずズレるengine-core の GameClock / withGameClock / resolveDeadline に集約し、3 実装はそれを使う。各実装に残るのは永続化・tick ループ・broadcast だけ

残存する窓 — 稼働中の部屋は弾けない

IsPlayable() はプレイ開始前の門なので、止められるのは新しくプレイを始める経路だけ。 デプロイの瞬間に WebSocket を張っている部屋は、そのまま新しいエンジンで動き続ける。

旧 revision の logic.js は ctx.now を読むが、新エンジンの ctx にそのキーは無い。 state.endsAt = ctx.now + 尺 は undefined + 尺 = NaN になり、締切が二度と来ない。 例外も出ないので、放っておくと何も起きないまま公演が進む。

対処は 2 段構え。

  1. 公演と公演の隙間にデプロイする(一次対策。窓そのものを作らない)
  2. at() が有限でない値を返したら、締切ごとに 1 回必ず console.error を出す (engine-core の resolveDeadline)。踏んだときに気付けるようにするための保険

play-server 側で wire 版を突き合わせて門前払いする案は採らない。 versioning の「判定するのが backend だけなので、play-server に 同じ数値を置いても使われず、乖離する余地を作るだけになる」に反するため。 R2 の meta.json には wireVersion が入っているので、この判断を覆すなら材料は揃っている。

PR の切り方 ​

monorepo 側は 1 本にまとめる。engine-core の型を変えた瞬間に他が全部落ちるので、分けると main が壊れたまま止まる。

uzuPJ/app        1 PR : engine-core → play-server → sdk → cli → packages/uzu_code → mobile
uzu-scenarios    4 PR : silk-and-chrome / kikaidochu / tenbo-danwashitsu / coastal-council

シナリオ側は別リポなので分かれる。順序が要点になる。

  1. シナリオ 4 本の PR を先に用意して merge せずに置いておく(monorepo の branch を npm link して手元で通す)
  2. monorepo の PR を merge → play-server をデプロイ
  3. 即座にシナリオ 4 本を merge して再 publish

2 と 3 のあいだ、旧 revision は契約版の下限に引っかかって 既存の口(IsPlayable() → UPDATE_REQUIRED)で止まる。 壊れた画面ではなく「プレイできません」になるので、窓が空いても事故にはならない。

旧 API を残して後追い移行、はやらない

ctx.now を deprecated として残せばシナリオ移行を後回しにできるが、epoch が違う。 同じ名前で別の意味を持つ API を並走させることになり、 このページが直そうとしている問題そのものを作る。

移行は一度で終わらせる。

テスト観点 ​

  • 締切基準(kikaidochu / silk-and-chrome)と tick 基準(shogi)の両方で、10 分停止 → 解除して残り時間が変わらないこと。本番コンテンツは前者なので、こちらを主にする
  • at() が GameTime を要求することで、Date.now() 由来の値がコンパイルで弾かれること
  • 端末の時計を手で 1 時間ずらしてもカウントダウンが飛ばないこと(performance.now() 基準の確認)
  • 停止中に締切の実時刻を通過 → 解除後に停止ぶんだけ後ろへずれて発火すること
  • setup() 直後の ctx.time が 0 であること。停止を挟んでも単調非減少であること
  • 停止直前に投げた action が Paused で弾かれ、先読みが rollback されること
  • 停止中に再接続した端末に overlay が出ること
  • facet の evict / ENGINE_VERSION 入れ替えをまたいで停止が保たれること
  • 全員切断 → 再入室で停止が保たれること
  • ソロ / dev harness / 本番の 3 実装で挙動が揃うこと

実装順 ​

  1. engine-core — GameTime / Duration / plus / minus、ctx.time / ctx.after、Deadline.at(): GameTime | null。型だけの変更で、ここが移行の号砲になる
  2. play-server — ゲーム内時計(スカラー 3 本)・同期ハンドラの Date.now 差し替え・__pause / __resume / __pause_state・永続化・tick ゲート
  3. SDK — gameTime()(performance.now() 基準)と停止中の凍結、send() のゲート、bridge 中継、getPause / onPauseChange、描画経路への罠の拡張
  4. cli / dev-server / local-server-action — 同じ時計を 3 実装に入れる
  5. シナリオ 4 本 — ctx.now → ctx.time / ctx.after、serverNow() → gameTime()。tsc が全箇所を指す
  6. packages/uzu_code — overlay と PlayScreenController の API
  7. mobile — UZU メニューへの結線と音声の切り替え
  8. 契約版の下限を上げてデプロイ — 公演の無い時間帯に
  9. UZU TOKYO — play_room_uzu_code.dart に onMenuPressed を繋ぐ(後続フェーズ)

1 〜 5 はまとめて 1 本の PR にする。型を変えた瞬間にシナリオが落ちるので、分けると main が壊れた状態で止まる。