テーマ
開発パターン
SDK は 3 つのパラダイムを提供しています。ゲームの要件に応じて選択してください。
パラダイムと接続方式の関係
ここで説明する 3 つのパラダイム (Relay / run() / sync()) は SDK レベルの API パターン であり、サーバーインフラの接続方式とは別レイヤーの概念です。
- Relay → Relay方式 (RelayRoom DO) を使用
sync()→ SyncRoom方式 (SyncRoom DO) を使用。クライアントが任意の JSON Patch を送信し、DO がそのまま適用run()→ GameRoom DO を使用(Server-Authoritative)。サーバーが reducer を実行し、state を管理する。manifest.jsonにserverActionLogicPathを指定してサーバーロジックをデプロイする
パラダイム比較
| Relay | sync() | run() | |
|---|---|---|---|
| 概要 | 低レベルメッセージング | JSON Patch ベースの状態同期 | reducer ベースのゲームループ |
| API | init() + onRoom() | sync({ initialState, onState, inputs }) | run({ logic, onState, inputs }) |
| state 管理 | ゲーム側の責務 | SDK + サーバーが保存・同期 | SDK + reducer が管理 |
| ロジック実行 | ゲームが自由に決める | 各クライアントが patch を送信 | サーバー(GameRoom DO)が reducer を逐次実行 |
| 状態条件の保証 | - | なし(後述) | あり(reducer は最新 state に対して実行される) |
| 向いているゲーム | 独自プロトコルが必要なゲーム | 単純なターン制・ボードゲーム | リアルタイム・アクション・ターン制問わず汎用 |
| 使用する接続方式 | Relay | SyncRoom | GameRoom |
パターン 1: Relay(低レベルメッセージング)
プレイヤー間でメッセージを自由に送受信する最もシンプルな方式です。state 管理はゲーム側の責務になります。
typescript
import { init, on, onRoom } from '@uzuhq/code-sdk';
import type { PlayersChangedMessage } from '@uzuhq/code-sdk';
init();
// Flutter からのプレイヤー状態を受信(音声・在席状態)
on('playersChanged', (msg) => {
const { players } = msg as unknown as PlayersChangedMessage;
for (const [id, state] of Object.entries(players)) {
updatePlayerAvatar(id, state.audioStatus);
}
});
// Room に接続されたらメッセージの送受信を開始
onRoom((room) => {
// メッセージ受信
room.on('attack', (msg) => {
console.log(`${msg.__from} sent ${msg.lines} lines`);
game.receiveAttack(msg.lines);
});
// 全員にメッセージ送信
room.broadcast({ type: 'attack', lines: 2 });
// 特定プレイヤーに送信
room.send(targetId, { type: 'whisper', text: 'hello' });
});特徴:
- SDK は接続管理とメッセージ転送のみ。ゲームロジックは完全に自由
- 構造化された同期が不要なゲームに最適
メッセージロスに注意
Relay はメッセージを「その場で転送する」だけで、サーバー側に状態を保持しません。そのため、相手がオフラインだったりアプリを再起動した場合、その間に送信されたメッセージは失われます。
パターン 2: sync() — patch ベース状態同期
JSON Patch ベースの宣言的な状態変更で、全クライアントが平等にサーバーへ patch を送信します。
typescript
import { sync, SERVER_TIME } from '@uzuhq/code-sdk';
import type { Seat } from '@uzuhq/code-sdk';
interface GameState {
board: (string | null)[][];
currentPlayer: string;
scores: Record<string, number>;
lastMoveAt: number;
}
let currentState: GameState | null = null;
let myId = '';
sync<GameState>({
playerCount: 2,
initialState(players: Seat[]) {
return {
board: Array.from({ length: 8 }, () => Array(8).fill(null)),
currentPlayer: players[0].id,
scores: Object.fromEntries(players.map((p) => [p.id, 0])),
lastMoveAt: 0,
};
},
onState(state, myPlayerId, serverTime) {
currentState = state;
myId = myPlayerId;
render();
},
inputs(patch, set) {
canvas.addEventListener('click', (e) => {
if (!currentState || currentState.currentPlayer !== myId) return;
const { row, col } = getCellFromClick(e);
// 複数の操作をまとめて送信
patch([
{ op: 'replace', path: `/board/${row}/${col}`, value: myId },
{ op: 'replace', path: '/lastMoveAt', value: SERVER_TIME },
]);
// 単一値の変更はショートカットも使える
set('/currentPlayer', getNextPlayer());
});
},
connection: {
onConnectionStateChange(state) {
if (state === 'disconnected') {
showDisconnectedOverlay();
}
},
onPatchFailed(error) {
console.error('Patch failed:', error);
},
},
});特徴:
- 全クライアント平等
- JSON Patch で宣言的に状態変更
- 楽観的更新で低レイテンシ体験
楽観的更新の仕組み
sync() は送信した patch をローカルに即座に適用し、onState() で即座に反映します。サーバーからの権威的な state を受信すると、ローカルの state を上書きします。
1. ユーザーがクリック
2. set('/players/myId/choice', 'rock')
3. SDK がローカルに即適用 → onState() → 画面に即反映 (0ms)
4. patch をサーバーに送信
5. サーバーが apply → state を全員に broadcast
6. SDK がサーバー state で上書き → onState() → 補正(通常は差分なし)競合を避ける state 設計
同時に複数プレイヤーが異なるパスを変更する場合、競合は起きません。
typescript
// 良い設計: プレイヤーごとに別パス
{
players: {
alice: { choice: null, score: 0 }, // /players/alice/choice
bob: { choice: null, score: 0 }, // /players/bob/choice
}
}
// → alice と bob が同時に自分の choice を変更しても競合しないパターン 3: run() — reducer ベースゲームループ
GameLogic(reducer)を定義し、SDK がゲームループを管理します。サーバー(GameRoom DO)が reducer を実行するため、チート耐性が高く、全プレイヤーが平等なレイテンシで接続します。
manifest.json の設定
run() を使うゲームでは、manifest.json に serverActionLogicPath を指定します。このフィールドがサーバーで実行するロジックのエントリポイントを示し、uzu publish 時に自動でサーバーにデプロイされます。
json
{
"id": "snake-battle",
"serverActionLogicPath": "./src/logic.ts",
"playerCount": 2,
"build": "pnpm run build",
"output": "dist"
}| 特性 | 値 |
|---|---|
| reducer 実行場所 | サーバー(GameRoom DO) |
| 全員平等 | 全員 ~10-40ms(国内エッジ) |
| ホスト切断時 | 影響なし(DO が継続) |
| チート耐性 | ◎(サーバーが reducer で検証) |
ゲームロジックの定義
typescript
import type { GameLogic } from '@uzuhq/code-sdk';
export interface MyState {
players: Record<string, { x: number; y: number; score: number }>;
foods: Array<{ x: number; y: number }>;
}
export const logic: GameLogic<MyState> = {
// 初期 state を生成。seats には観戦系の席 (kind: 'spectator' | 'admin') も
// 含まれ得るため、ゲームの席は kind === 'player' に絞る
setup(seats, random) {
const players = seats.filter((p) => p.kind === 'player');
const state: MyState = { players: {}, foods: [] };
for (const p of players) {
state.players[p.id] = {
x: random.int(20),
y: random.int(20),
score: 0,
};
}
// 食べ物を配置
for (let i = 0; i < 5; i++) {
state.foods.push({ x: random.int(20), y: random.int(20) });
}
return state;
},
// アクションハンドラ (state を直接変更)
actions: {
move(state, payload, playerId, emit) {
const player = state.players[playerId];
if (!player) return;
player.x += payload.dx;
player.y += payload.dy;
// 食べ物との衝突チェック
const foodIndex = state.foods.findIndex((f) => f.x === player.x && f.y === player.y);
if (foodIndex >= 0) {
state.foods.splice(foodIndex, 1);
player.score += 1;
emit('sound', { sound: 'eat' }); // イベント発火
}
},
},
// 毎 tick 実行
update(state, ctx) {
// tick ベースのロジック (例: 時間経過で食べ物追加)
if (ctx.tick % 50 === 0 && state.foods.length < 10) {
state.foods.push({
x: ctx.random.int(20),
y: ctx.random.int(20),
});
}
},
tickRate: 10, // 秒間 10 tick
};エントリーポイント
typescript
import { run } from '@uzuhq/code-sdk';
import { logic, type MyState } from './logic';
let currentState: MyState | null = null;
let myId = '';
run({
logic,
onState(state, myPlayerId) {
currentState = state;
myId = myPlayerId;
},
inputs(sendAction) {
document.addEventListener('keydown', (e) => {
const dirs: Record<string, { dx: number; dy: number }> = {
ArrowUp: { dx: 0, dy: -1 },
ArrowDown: { dx: 0, dy: 1 },
ArrowLeft: { dx: -1, dy: 0 },
ArrowRight: { dx: 1, dy: 0 },
};
const dir = dirs[e.key];
if (dir) sendAction('move', dir);
});
},
events: {
sound(data) {
new Audio(`/sounds/${data.sound}.mp3`).play().catch(() => {});
},
},
playerCount: 2,
});
// 描画ループ
function render() {
if (currentState) {
// currentState を使って描画
}
requestAnimationFrame(render);
}
render();特徴:
logicを定義するだけで SDK がゲームループを管理- tick ベースのリアルタイムゲームにもターン制ゲームにも対応(
tickRate: 0でターン制) emit()でサウンド等のイベントを発火可能- reducer は最新の state に対して逐次実行されるため、状態条件が保証される(詳細)
サーバー専用 action: serverOnly()
actions に登録した handler は通常、クライアント側で楽観的更新のために先行実行され、その後サーバーに送信されて確定します。しかし fetch などの 副作用付き処理 をクライアントで先行実行されたくない場合があります(HTTP リクエストが二重に飛ぶ・iPad アプリの CORS 等で予期せぬ挙動になる等)。
serverOnly() で wrap した handler は クライアントの先行実行をスキップ し、サーバー側でのみ実行されます。async も使えるため fetch を await できます。
typescript
import { serverOnly, type GameLogic } from '@uzuhq/code-sdk';
export const logic: GameLogic<MyState> = {
setup(seats, random) {
/* ... */
},
actions: {
// 通常: client 楽観更新 + server 確定 (同期)
startGame(state) {
state.status = 'started';
},
// serverOnly: client では先行実行されず、server だけで実行 (async OK)
notifyExternal: serverOnly(async (state, payload, playerId) => {
await fetch('https://example.com/notify', {
method: 'POST',
body: JSON.stringify({ playerId, ...payload }),
});
state.notifiedAt = Date.now();
}),
},
update(state, ctx) {
/* ... */
},
};動作:
- クライアントは先行実行をスキップし、サーバーからの状態反映 (
__action_result_delta) を待つ - サーバーは handler を
awaitで実行し、完了後に state delta をブロードキャストする - handler 内で
stateを変更すれば、通常の action と同じく差分が全クライアントに配信される - handler 内の例外は
__action_errorとして送信元クライアントに返る
使い所:
- 外部 API への HTTP リクエスト発行(プッシュ通知、webhook 等)
- サーバーで保持する secret を使った処理
- 二重実行されては困る副作用全般
注意:
await fetch(...)中はクライアントに state が反映されないため、長時間の処理は UX に注意serverOnly()内で投げたエラーはクライアントに__action_errorとして届くが、楽観更新していないのでロールバックは不要- 副作用付き処理を書くときは 必ず
serverOnly()で wrap する こと。TypeScript の関数型はvoid戻りがPromise<void>を受け入れるため、serverOnly()を付け忘れた async handler は型エラーにならず、クライアントの楽観更新でも実行されてしまう(fetchが二重に飛ぶ等) - handler の並列実行に注意: Cloudflare Durable Object の input gate は ストレージ I/O 待ち中は他メッセージをブロックする が、
await fetch()のような外部 async 呼び出し中はブロックしない(公式 docs 参照)。そのため、ある serverOnly handler がfetchを待っている間に別のクライアントから来た action が並列実行され、stateを競合的に書き換える可能性がある - 対策として、serverOnly handler の 副作用 (
fetch等) は冪等にする か、state 変更はfetchの前 / 後の同期セクションで行う 設計を推奨する。solo モード(?roomIdなし)や emulator モード(BroadcastChannelベース)でも同様にキューイングされない
3 つの実行モード: online / emulator / solo
run() で書いたゲームは、開発・本番で 3 つの実行モードを持ちます。開発中に「solo で動いたのに online で動かない」「逆に online でしか再現しない」とならないよう、3 モードは可能な限り同じ挙動になるよう揃えています。
| online (本番) | emulator (?roomId=...) | solo (?roomId なし) | |
|---|---|---|---|
| 構造 | クライアント + GameRoom DO | クライアント + 仮想サーバー (BroadcastChannel、players[0] の iframe で起動) | クライアント単体 (state も同居) |
sendAction() 直後の挙動 | standard handler を同期で先行実行 → onState() 同期発火 | 同左 | 同左 |
| standard handler の実行回数 | 2 回 (client 楽観 + server 確定) | 2 回 (client 楽観 + 仮想サーバー確定) | 1 回 (client のみ、確定段階なし) |
serverOnly() handler | client では skip (サーバーで await 実行 → events は配信される) | client では skip (仮想サーバーで await 実行) | client = サーバーなので 1 回だけ await 実行 |
| events の発火タイミング | 楽観実行時に client から emit、サーバー ack 時は重複排除 (isMyAck) で skip | 同左 (仮想サーバー ack 時に重複排除) | 楽観実行時に発火、確定段階がないので重複は起きない |
| handler 内例外時 | pending から除去して再適用 (ロールバック)、__action_error が返る | 同左 | events / state 更新を破棄 |
副作用は冪等に書く
standard handler は online / emulator では 2 回実行されるため、副作用 (DOM 操作、外部 API 呼び出し、playSound など) は冪等になるよう書いてください。 副作用を 1 回だけ実行したい場合は serverOnly() で wrap してサーバー側に閉じ込めるか、events 経由で sub クライアントから発火してください (events は重複排除されます)。
solo モードでは 1 回しか実行されないため、副作用の二重発火に気付かないことがあります。本番投入前に必ず emulator か online で確認してください。
run() vs sync(): 状態条件の保証
run() と sync() の最も重要な設計上の違いは、action 実行時に state の条件が保証されるかどうかです。
問題: sync() の楽観的実行
sync() では、クライアントがローカルの state を見て条件判定し、patch を送信します。しかし その patch が DO に届いた時点で、state は他のプレイヤーの patch によって既に変わっている可能性があります。DO は patch の内容を検証せずそのまま適用するため、条件を満たさない変更が通ってしまいます。
typescript
// 例: 2人が同時に宝箱を開ける
// sync() の場合
// Client A: state.chest.owner === null → 条件OK
patch([{ op: 'replace', path: '/chest/owner', value: 'alice' }]);
// Client B: state.chest.owner === null → 条件OK(まだ A の patch が届いていない)
patch([{ op: 'replace', path: '/chest/owner', value: 'bob' }]);
// DO: A の patch 適用 → owner = 'alice'
// B の patch 適用 → owner = 'bob'(A が取得済みなのに上書きされる)
// 結果: bob が取得(後勝ち)— ゲームルール上は不正な状態遷移解決: run() の reducer による逐次実行
run() では、action がサーバー(GameRoom DO)の reducer で最新の state に対して逐次実行されます。reducer 内の条件チェックは、その時点の正確な state に基づきます。
typescript
// 例: 2人が同時に宝箱を開ける
// run() の場合
// logic.ts
actions: {
openChest(state, _payload, playerId) {
if (state.chest.owner !== null) return; // 条件チェック — 最新 state に対して実行される
state.chest.owner = playerId;
},
},
// A の action 到着 → reducer 実行: owner === null → owner = 'alice'
// B の action 到着 → reducer 実行: owner === 'alice' → 条件不成立、何もしない
// 結果: alice が取得(先勝ち)— ゲームルールが正しく適用されるどちらを選ぶべきか
| ゲームの特性 | 推奨 |
|---|---|
| 同時操作で同じリソースを奪い合う可能性がある | run() |
| 各プレイヤーが自分の領域だけを変更する(パスが競合しない) | sync() で十分 |
| 条件付きの状態遷移がある(「まだ誰も取っていなければ取れる」等) | run() |
| 単純な値の書き込みのみ(じゃんけんの手を選ぶ等) | sync() で十分 |
サウンド再生
いずれのパターンでも、SDK 経由で Flutter ホストにサウンド再生をリクエストできます。
typescript
import { playSound, playBgm, stopBgm } from '@uzuhq/code-sdk';
// 効果音
playSound('sounds/clear.mp3');
// BGM
playBgm('bgm/main.mp3'); // ループ再生
stopBgm();サウンドファイルはゲームの public/ ディレクトリに配置してください。Flutter 側のホストアプリがファイルパスを解決して再生します。
接続状態の管理
sync() と run() は接続状態を自動管理します。切断時の自動再接続、メッセージバッファリングも SDK が処理します。
typescript
sync({
// ...
connection: {
onConnectionStateChange(state) {
// 'connecting' → 'connected' → (切断) → 'reconnecting' → 'connected'
// → (最大試行到達) → 'disconnected'
switch (state) {
case 'connected':
hideOverlay();
break;
case 'reconnecting':
showReconnectingOverlay();
break;
case 'disconnected':
showDisconnectedOverlay();
break;
}
},
},
});