@uzuhq/code-sdk
v0.8.2
Published
UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ
Readme
@uzuhq/code-sdk
UZU 上で動くゲーム・シナリオを作るための通信 SDK。マルチプレイ状態同期、サウンド再生、デバイス連携を提供します。
npm install @uzuhq/code-sdkゲームのビルド・配信には @uzuhq/code-cli を併用してください。
メッセージプロトコル
すべてのブリッジメッセージは { channel, type, payload } の共通構造を持つ。
| チャネル | 用途 | 説明 |
| -------- | ---------------------------- | ------------------------------------------------------------- |
| sdk | SDK/プラットフォームコマンド | サウンド再生、マイク制御、ルーム移動など SDK 内部のメッセージ |
| game | ゲーム独自メッセージ | send() / on() で送受信するカスタムメッセージ |
send() は channel: 'game' のメッセージのみを送信する。on() は channel: 'game' のメッセージのみを受信し、handler には payload が直接渡される。
SDK チャネルのメッセージは playSound(), setMicEnabled() などの専用関数から自動送信される。
3 つのパラダイム
SDK は用途に応じて 3 つの API パターンを提供する。
| パラダイム | API | state 管理 | 向いているゲーム |
| ---------- | --------------------- | ---------------------------------- | ---------------------------- |
| Relay | init() + onRoom() | ゲーム側の責務 | 独自プロトコルが必要なゲーム |
| sync() | sync() | SDK + サーバーが JSON Patch で同期 | ターン制・ボードゲーム |
| run() | run() | SDK + reducer がサーバーで逐次実行 | リアルタイム・アクション全般 |
選択基準
- 同時操作で同じリソースを奪い合う →
run()(reducer が最新 state に対して逐次実行、条件保証あり) - 各プレイヤーが自分の領域だけ変更 →
sync()で十分 - 独自のメッセージプロトコルが必要 → Relay
初期化
init(): void
SDK を初期化し、Flutter ホスト / 親ウィンドウに準備完了を通知する。
import { init } from '@uzuhq/code-sdk';
init();run()/sync()を使う場合は内部でinit()が呼ばれるため不要- Relay パターン (
onRoom()) のみ、最初に呼び出す
パターン 1: Relay
onRoom(callback: (room: RoomLike) => void): void
Room に接続されたときのコールバックを登録する。
import { init, onRoom } from '@uzuhq/code-sdk';
init();
onRoom((room) => {
console.log('My ID:', room.myId);
room.on('attack', (msg) => {
console.log(`${msg.__from} sent ${msg.lines} lines`);
});
room.broadcast({ type: 'attack', lines: 2 });
room.send(targetId, { type: 'whisper', text: 'hello' });
});Room API
| メソッド/プロパティ | 型 | 説明 |
| ------------------------ | ------------------------------------------------------ | -------------------------------------------------------- |
| room.myId | string (readonly) | 自分の playerId |
| room.broadcast(msg) | (msg: Record<string, unknown>) => void | 自分以外の全員に送信 |
| room.send(id, msg) | (id: string, msg: Record<string, unknown>) => void | 特定プレイヤーに送信 |
| room.on(type, handler) | (type: string, handler: (data: any) => void) => void | メッセージハンドラ登録。受信データに __from が含まれる |
パターン 2: sync()
sync<S>(config: SyncConfig<S>): void
JSON Patch ベースの状態同期を開始する。
import { sync, SERVER_TIME } from '@uzuhq/code-sdk';
import type { Seat } from '@uzuhq/code-sdk';
sync<GameState>({
playerCount: 2,
initialState(players: Seat[]) {
return { board: createBoard(8), currentPlayer: players[0].id };
},
onState(state, myPlayerId, serverTime) {
currentState = state;
render();
},
inputs(patch, set) {
canvas.addEventListener('click', (e) => {
const { row, col } = getCellFromClick(e);
// 複数操作をまとめて送信
patch([
{ op: 'replace', path: `/board/${row}/${col}`, value: myPlayerId },
{ op: 'replace', path: '/lastMoveAt', value: SERVER_TIME },
]);
// 単一値のショートカット
set('/currentPlayer', getNextPlayer());
});
},
connection: {
onConnectionStateChange(state) {
console.log('Connection:', state);
},
onPatchFailed(error) {
console.error('Patch failed:', error);
},
},
});SyncConfig
| キー | 型 | 必須 | 説明 |
| -------------- | ------------------------------------------------------------ | ---- | ---------------------------- |
| playerCount | number | Yes | プレイヤー数 |
| initialState | (players: Seat[]) => S | Yes | 初期 state を生成 |
| onState | (state: S, myPlayerId: string, serverTime: number) => void | Yes | state 更新時のコールバック |
| inputs | (patch: PatchFn, set: SetFn) => void | Yes | 入力ハンドラ登録 |
| events | Record<string, (data: Record<string, unknown>) => void> | No | ゲームイベントハンドラ |
| connection | ConnectionCallbacks | No | 接続状態・エラーコールバック |
PatchFn / SetFn
type PatchFn = (ops: Operation[]) => void; // 複数操作をまとめて送信
type SetFn = (path: string, value: unknown) => void; // 単一パスの replace ショートカットOperation (JSON Patch)
interface Operation {
op: 'replace' | 'add' | 'remove';
path: string; // JSON Pointer パス (例: '/players/alice/score')
value?: unknown; // 値。SERVER_TIME sentinel 使用可
}楽観的更新
sync() は送信した patch をローカルに即座に適用する。サーバーからの権威的な state を受信すると上書きする。
注意: sync() は patch の条件を検証しない。同時に同じパスを変更すると後勝ちになる。条件付きの状態遷移が必要なら run() を使う。
パターン 3: run()
run<S>(config: GameConfig<S>): void
Host-authoritative ゲームループを実行する。サーバー (GameRoom DO) が reducer を逐次実行するため、条件保証あり。
import { run } from '@uzuhq/code-sdk';
run({
logic,
onState(state, myPlayerId) {
currentState = state;
myId = myPlayerId;
},
inputs(sendAction) {
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') sendAction('move', { dx: 0, dy: -1 });
});
},
events: {
sound(data) {
new Audio(`/sounds/${data.sound}.mp3`).play().catch(() => {});
},
},
playerCount: 2,
});GameConfig
| キー | 型 | 必須 | 説明 |
| ------------------------- | -------------------------------------------------------------- | ---- | ----------------------------------- |
| logic | GameLogic<S> | Yes | ゲームロジック定義 |
| onState | (state: S, myPlayerId: string) => void | Yes | state 更新時のコールバック |
| inputs | (sendAction: (action: string, payload: any) => void) => void | Yes | 入力ハンドラ登録 |
| events | Record<string, (data: any) => void> | No | ゲームイベントハンドラ |
| playerCount | number | Yes | プレイヤー数 |
| onConnectionStateChange | (state: ConnectionState) => void | No | 接続状態変化コールバック |
| onPatchFailed | (reason: string) => void | No | サーバー patch 適用失敗コールバック |
GameLogic
import type { GameLogic } from '@uzuhq/code-sdk';
const logic: GameLogic<MyState> = {
setup({ players, ctx }) {
// 初期 state を生成。ctx.random は SeededRandom、ctx.time はゲーム内時刻 (必ず 0)。
// players は配役を受け取る参加者だけで、観測者は含まれない
return { players: {}, items: [] };
},
// クライアント先読みとサーバーの 2 回走る。決定的でなければならない。
actions: {
move({ state, payload, playerId, ctx }) {
// state を直接変更する(Immer 的な mutable 操作)
state.players[playerId].x += payload.dx;
// ctx.emit でイベント発火 (購読側は events で predict を宣言する)
ctx.emit('sound', { sound: 'step' });
// 時刻は ctx.after(d) を使う。Date.now() は epoch が違う (Unix ms vs ゲーム内時刻)
},
},
// サーバーでのみ走る。実時刻・乱数・fetch などクライアントが再現できない処理。
// actions と同名にすると「同じ action のサーバー側の続き」になる。
serverActions: {
async notifyExternal({ state, payload, playerId, ctx }) {
await fetch('https://example.com/notify', {
method: 'POST',
body: JSON.stringify({ playerId, ...payload }),
});
state.notifiedAt = ctx.time;
},
},
update({ state, ctx }) {
// 毎 tick 実行。ctx.tick / ctx.random / ctx.time / ctx.after / ctx.emit / ctx.playerInputs が使える
},
tickRate: 10, // 秒間 tick 数 (default: 0 = tick なし)
};| キー | 型 | 必須 | 説明 |
| --------------- | ---------------------------------------- | ---- | --------------------------------------------------------------- |
| setup | (args: SetupArgs) => S | Yes | 初期 state を生成。args.players は配役を受け取る参加者だけ |
| actions | Record<string, ActionHandler<S>> | Yes | クライアント先読み + サーバーの 2 回走る。決定的であること |
| serverActions | Record<string, ServerActionHandler<S>> | No | サーバーでのみ走る。async 可。ctx に tick / random が入る |
| update | (args: UpdateArgs<S>) => void | Yes | 毎 tick 実行 (tickRate が 0 なら呼ばれない) |
| tickRate | number | No | 秒間 tick 数 (default: 0 = tick なし) |
ハンドラの引数は 1 つのオブジェクトで、使うものだけ書けばよい。
state / payload / playerId はその呼び出しの事実、ctx は実行環境が与えるもの。
deadlines
「state のこの時刻を過ぎたらこれをする」を宣言する。サーバーが最も早い at に合わせて
自分で起き、過ぎた締切の handler を呼ぶ。
deadlines: {
phaseTimer: {
at: ({ state }) => state.timerEndsAt, // null / undefined なら締切なし
handler: ({ state, ctx }) => {
state.phase = nextPhase(state);
ctx.emit('phase.changed', { phase: state.phase });
},
},
},| | |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| at | 締切のゲーム内時刻 (GameTime)。null / undefined で締切なし (optional chain の結果をそのまま返せる)。state だけから決まる軽い純関数にすること (state が変わるたびに呼ばれる) |
| handler | サーバーでのみ走る。ctx は { time, after, random, emit } |
締切は state から導出するので、予約を張り替える処理を書かなくてよい。action が throw して
state が巻き戻れば締切も一緒に巻き戻る。発火直前に at を評価し直すので、二重発火を
handler 側で弾く必要も無い。
時刻をきっかけに何かを起こすなら tickRate で毎秒ポーリングせずこちらを使う。
ポーリング中は Durable Object が hibernate できない。
ゲーム内時計
時刻は Unix epoch ではない。 ctx.time はゲーム開始からの経過 ms (GameTime) で、
緊急一時停止中は進まない。Date.now() とは桁も意味も違う。
| API | 意味 |
| -------------------------- | ----------------------------------------------------------------- |
| ctx.time | そのハンドラが走っているゲーム内時刻。setup では必ず 0 |
| ctx.after(d) | 今から d ms 後のゲーム内時刻。締切を state に置くときに使う |
| gameTime() | 描画側で読む現在のゲーム内時刻 (推定値)。performance.now() 基準 |
| plus(t, d) / sub(t, d) | 時刻に長さを足す / 引く |
| minus(a, b) | 2 つの時刻の差 (ms) |
import { gameTime, minus } from '@uzuhq/code-sdk';
// 締切を置く
actions: {
startPhase: ({ state, ctx }) => { state.phaseEndsAt = ctx.after(5 * 60_000); },
},
// 残り時間を描く
const remain = Math.ceil(minus(state.phaseEndsAt, gameTime()) / 1000);GameTime は brand 型なので実行時はただの数値で、state は素の JSON のまま。ただし型の上では
number と混ざらないので、Date.now() を書き込む式も endsAt - Date.now() と読む式も
コンパイルが通らない。
緊急一時停止
プレイヤーが UZU メニューから全員のタイマーを止められる。シナリオは停止を知らないし、
知る必要も無い。 停止中は update() が呼ばれず、action はサーバーが弾き、締切も来ない。
演出だけを止めたいときに限り、読み取り専用で参照できる。
import { isPaused, onPauseChange } from '@uzuhq/code-sdk';
onPauseChange((paused) => (paused ? engine.stop() : engine.start()));GameLogic からは触れない。停止を state に持ち込むと「停止中は state が変わらない」という
前提が崩れる。
serverOnly(handler)
Deprecated:
serverActionsに直接書く。
actions に登録する handler を「サーバーでだけ実行される」ものに変換する旧 wrapper。
serverActions フィールドが同じことを型で表現できるので、新しいコードでは使わない。
// before
actions: { notifyExternal: serverOnly(async (state) => { ... }) }
// after
serverActions: { notifyExternal: async ({ state }) => { ... } }manifest.json(run() を使う場合)
run() を使う場合、manifest.json に serverActionLogicPath を指定する:
{
"id": "my-game",
"serverActionLogicPath": "./src/logic.ts",
"playerCount": 2,
"build": "npm run build",
"output": "dist"
}SeededRandom
GameLogic の setup / update で提供される seed 付き乱数生成器。
| メソッド | 説明 |
| ---------------- | ----------------------------------- |
| float() | 0.0〜1.0 の浮動小数点 |
| int(max) | 0〜max-1 の整数 |
| pick(array) | 配列からランダムに 1 つ選択 |
| shuffle(array) | 配列をシャッフル (新しい配列を返す) |
サウンド
import { playSound, playBgm, stopBgm } from '@uzuhq/code-sdk';
playSound('sounds/clear.mp3'); // 効果音
playBgm('bgm/main.mp3'); // BGM ループ再生
stopBgm(); // BGM 停止サウンドファイルはゲームの public/ ディレクトリに配置する。
デバイス連携
setMicEnabled(enabled: boolean): void
マイクのオンオフを切り替える。
import { setMicEnabled } from '@uzuhq/code-sdk';
setMicEnabled(false); // マイクをミュート
setMicEnabled(true); // マイクをオンchangeRoom(roomId: string | null): void
ボイスチャットルームを移動する。null でデフォルトルーム(全体ルーム)に戻る。
import { changeRoom } from '@uzuhq/code-sdk';
changeRoom('room_a'); // room_a に移動(同じルームのプレイヤーとのみ音声通話可能)
changeRoom(null); // デフォルトルーム(全員が同じ音声チャンネル)に戻る- 初期状態ではすべてのプレイヤーがデフォルトルーム(
null)に所属する roomIdに文字列を指定すると、そのプレイヤーは指定ルームへ移動する
onPlayersChanged(handler: (players: Record<string, PlayerVoiceState>) => void): void
プレイヤーのリアルタイム状態(音声状態)が変化したときのハンドラを登録する。
import { onPlayersChanged } from '@uzuhq/code-sdk';
onPlayersChanged((players) => {
for (const [id, state] of Object.entries(players)) {
// state.audioStatus: 'speaking' | 'listening' | 'muted' | 'unstable' | null
updatePlayerUI(id, state.audioStatus);
}
});メッセージング
send(type: string, payload?: object): void
Flutter ホスト / 親ウィンドウへゲームチャネル (channel: 'game') のメッセージを送信する。
import { send } from '@uzuhq/code-sdk';
send('attack', { lines: 2 });
// → { channel: 'game', type: 'attack', payload: { lines: 2 } }on(type: string, handler: (payload: any) => void): void
ゲームチャネル (channel: 'game') のメッセージを受信する。handler には payload が直接渡される。
import { on } from '@uzuhq/code-sdk';
on('attack', (payload) => {
console.log(`Received attack with ${payload.lines} lines`);
});:::caution
on() は channel: 'game' のメッセージのみを受信する。SDK チャネルのメッセージ(playersChanged 等)は onPlayersChanged() などの専用関数を使用すること。
:::
isHosted
boolean (読み取り専用)。Flutter WebView または iframe 内で動作しているか。
SERVER_TIME
サーバー時刻 sentinel 定数。Patch の value に指定すると、サーバー側で Date.now() に自動置換される。
import { SERVER_TIME } from '@uzuhq/code-sdk';
set('/meta/phaseStartedAt', SERVER_TIME);型定義
BridgeMessage
interface BridgeMessage {
channel: 'sdk' | 'game';
type: string;
payload: Record<string, unknown>;
}Seat
roster に載る席。roster は配役を受け取る参加者だけで構成される。席種別は roster エントリ
ではなく、自分の SeatKind として onState に渡る。
interface Seat {
id: string;
nickname: string;
iconUrl: string;
/** 選択済みキャラクターの ID。未選択時は undefined */
characterId?: string;
}SeatKind
自分の席種別。ホストが iframe URL の ?seatKind= で伝え、SDK が onState の第 3 引数
として渡す。
type SeatKind = 'player' | 'spectator' | 'admin';観戦席・進行管理席は roster に載らないまま接続してくる。setup() の players にも
現れないので「player か観測者か」は state.players の空振りで分かるが、spectator と
admin の区別は state から導けない。そこをこの値で分ける。
onState(state, myPlayerId, mySeatKind) {
const me = state.players.find((p) => p.playerId === myPlayerId);
if (me) return playerView(me);
// roster に居ない = 観測者
return mySeatKind === 'admin' ? gmView() : spectatorView();
}自己申告なので表示の分岐にだけ使う。渡るのは自分の席種別だけで、他プレイヤーの席種別は
サーバー側 handler (ActionArgs) にも渡らない。seatId の命名規約 (admin_0 等) から
判定すると、命名が変わった瞬間に静かに壊れるので避けること。
ConnectionState
type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';ConnectionCallbacks
interface ConnectionCallbacks {
onConnectionStateChange?: (state: ConnectionState) => void;
onPatchFailed?: (error: string) => void;
}PlayerVoiceState
interface PlayerVoiceState {
audioStatus: 'speaking' | 'listening' | 'muted' | 'unstable' | null;
}| audioStatus | 説明 |
| ----------- | ------------------------ |
| speaking | 発話中 |
| listening | 音声接続済み・聞いている |
| muted | ミュート中 |
| unstable | 接続に問題あり |
| null | 音声通話に未接続 |
URL パラメータ
init() / run() / sync() は以下の URL パラメータを読み取る。
| パラメータ | 説明 |
| ----------- | --------------------------------------------- |
| ?server= | WebSocket サーバーの URL |
| ?roomId= | ルーム ID。指定するとオンラインモードになる |
| ?seatId= | 自分の席 ID |
| ?players= | JSON エンコードされたプレイヤーリスト |
| ?__dev=N | Dev ハーネスモード (N 画面の iframe 並列表示) |
モードの自動判定
- URL に
?roomId=xxxがない → ローカルモード (サーバー不要) - URL に
?roomId=xxx&server=xxxがある → オンラインモード - URL に
?__dev=Nがある → Dev ハーネスモード
Dev Hooks (window.__uzu_dev)
外部 automation (Playwright / AI agent / E2E test) が iframe 内 state を決定論的に読み書きするための API。SDK は state shape に依存しない generic primitive だけを提供する。
Flutter native ホスト (window.FlutterHost あり) では一切 attach されない。authoritative state を持つ frame に 1 個だけ生える:
run()の devHarness → 親 frame だけ (page.mainFrame())runLocalServerAction(ソロモード) 単独 frame → その frame- sync 系単独 frame → その frame
- devHarness の 子 iframe には attach しない
window.__uzu_dev.getSnapshot(); // 現在 state (run devHarness 親 = authoritative raw)
window.__uzu_dev.getRawState(); // 生 server-side state (dev/local mode のみ)
window.__uzu_dev.playerId(); // 現在の player ID (run devHarness 親は null)
// action 送信 (run devHarness 親 frame のみ attach、それ以外は undefined)
// `as` は必須 — server-side dispatch の `from` を指定する
// Promise<void> を返す。await すると handler 完了 + broadcast 投函まで待つ
await window.__uzu_dev.send?.({ as: 'dev_0', type: 'host.phase.jump', payload: { phaseId: 'p1' } });
await window.__uzu_dev.send?.({
as: 'dev_1',
type: 'move.piece',
payload: { from: 'a1', to: 'a2' },
});
// illegal move は Promise rejection になる
await expect(
window.__uzu_dev.send({
as: 'dev_0',
type: 'move.piece',
payload: {/* 相手の手番 */},
}),
).rejects.toThrow('Not your turn');
// 生 state 書換は 3 API。用途別に使い分ける:
// 1. 全置換 (dump した state を流し込み、bug 再現など)
await window.__uzu_dev.setRawState(fullState);
// 2. RFC 7396 風 Merge Patch (object 階層の部分更新、array は atomic replace のみ)
await window.__uzu_dev.mergeRawState({ game: { timerEndsAt: null } });
// 3. RFC 6902 JSON Patch (path-based ops、array index 単体書換 OK)
await window.__uzu_dev.patchRawState([
{ op: 'replace', path: '/board/1/4', value: 99 },
{ op: 'replace', path: '/currentTurn', value: 'dev_1' },
]);
// snapshot が条件を満たすまで待つ (default 10s timeout)
await window.__uzu_dev.waitForSnapshot((s) => s.self.isReady === true);- state 書換 3 API:
setRawState(全置換) /mergeRawState(RFC 7396 風 Merge Patch) /patchRawState(RFC 6902 JSON Patch)- 部分更新は
mergeRawState、array 要素単体の書換はpatchRawStateを使う mergeRawStateで array field に non-array object patch を当てると throw する (array が pure object に化けるのを構造的に防止)
- 部分更新は
- online ServerAction では 3 API すべて
undefined sendは run devHarness 親 frame のみ。非 parent モード (runLocal / emulator / sync) では scenario 側のwindow.__uzu.sendActionを使うawait d.send(...)/await d.*RawState(...)は (a) handler 完了 (b) parent state broadcast 投函 までを待つ。子 iframe canvas の paint 完了は含まない ので、screenshot / visual e2e test ではawait new Promise(r => requestAnimationFrame(r))を別途挟むこと- phase 遷移時の field reset や
markReady等の state shape を仮定する helper は SDK には含めない。scenario 側でwindow.__<scene>_devを生やして上記 primitive を組み合わせる
scenario test/tools から型補完を効かせる
scenario の test/*.ts / tools/*.ts で SDK を import せず window.__uzu_dev だけ叩く場合、tsconfig.json の types に @uzuhq/code-sdk/dev-globals を足すと ambient で UzuDevHooks が効く:
{
"compilerOptions": {
"types": ["@uzuhq/code-sdk/dev-globals"],
},
}これで (window as any).__uzu_dev で殴らずに window.__uzu_dev?.send({ as: 'dev_0', type: '...', payload: ... }) が補完 + 型チェックされる。SDK を直接 import するモジュールでは dev-hooks.ts 側の declare global で既に型が見えているため、本 entry を types に足す必要はない。
詳細は docs/uzu_code/sdk-guide/dev-hooks.md を参照。
