テーマ
開発パターン
ゲームのロジックはサーバーで動きます。クライアントは action (プレイヤーの意図) を送り、 サーバーがシナリオの reducer で検証・実行し、結果の state を全員へ配ります。
reducer は常にその時点の最新 state に対して走ります。2 人が同時に同じリソースを取ろうと しても、片方の action が実行された後の state で次の action が判定されるので、 「まだ誰も取っていなければ取れる」のような条件がそのまま成立します。
typescript
actions: {
openChest: ({ state, playerId }) => {
if (state.chest.owner !== null) return; // 最新 state に対して判定される
state.chest.owner = playerId;
},
},
// A の action 到着 → owner === null → owner = 'alice'
// B の action 到着 → owner === 'alice' → 条件不成立、何もしないセットアップ
GameLogic(reducer)を定義すると、SDK がゲームループを管理します。
manifest.json の設定
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 を生成。players は配役を受け取る参加者だけで、観測者は含まれない
// (観測者は roster に載らず、onState の mySeatKind で GM / 観戦を分ける)
setup({ players, ctx }) {
const state: MyState = { players: {}, foods: [] };
for (const p of players) {
state.players[p.id] = {
x: ctx.random.int(20),
y: ctx.random.int(20),
score: 0,
};
}
// 食べ物を配置
for (let i = 0; i < 5; i++) {
state.foods.push({ x: ctx.random.int(20), y: ctx.random.int(20) });
}
return state;
},
// アクションハンドラ (state を直接変更)
actions: {
move({ state, payload, playerId, ctx }) {
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;
ctx.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 に対して逐次実行されるため、状態条件が保証される(詳細)
サーバー専用の処理: serverActions
actions に登録した handler は、クライアント側で楽観的更新のために先行実行され、その後サーバーでも実行されます(2 回走る)。そのため fetch などの副作用や、実時刻・乱数のようにクライアントで再現できない処理は書けません。
そういう処理は serverActions に置きます。こちらは サーバーでのみ 1 回だけ 実行され、async も使えます。
typescript
import { type GameLogic } from '@uzuhq/code-sdk';
export const logic: GameLogic<MyState> = {
setup({ players, ctx }) {
/* ... */
},
actions: {
// client 楽観更新 + server 確定 (2 回走る。決定的でなければならない)
startGame({ state }) {
state.status = 'started';
},
},
serverActions: {
// server だけで 1 回走る。async OK、ctx に { tick, random } が入る
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 }) {
/* ... */
},
};同名にすると 1 つの action を分割できる
actions と serverActions に同じキーを置くと、**同じ action の「クライアントでも走る部分」と「サーバーだけで走る部分」**になります。サーバーでは actions → serverActions の順に実行されます。
これにより、先読みの即応性を保ったまま、tick や乱数に依存する処理を混ぜられます。
typescript
actions: {
// 先読みされる = 駒は押した瞬間に動く
move({ state, payload, playerId }) {
movePiece(state, payload);
state.currentTurn = nextPlayer(state, playerId);
},
},
serverActions: {
// サーバーだけ = 持ち時間の減算 (tick に依存するので先読みできない)
move({ state, playerId, ctx }) {
state.timeRemaining[playerId] -= (ctx.tick - state.turnStartTick) * 1000;
state.turnStartTick = ctx.tick;
},
},actions に置かず serverActions だけにキーを置けば、その action は 先読みされない ものになります。
動作:
actionsに無い action 名は、クライアントは先行実行をスキップしてサーバーからの反映を待つ- サーバーは
actionsを同期実行したあとserverActionsをawaitし、完了後に state delta をブロードキャストする serverActionsがemitしたイベントは、送信元クライアントでも必ず配信される(先読みで発火していないため)- handler 内の例外は
__action_errorとして送信元クライアントに返る
使い所:
- 外部 API への HTTP リクエスト発行(プッシュ通知、webhook 等)
- サーバーで保持する secret を使った処理
- 二重実行されては困る副作用全般
- 乱数を state に書き込む処理(次項)。
ctx.randomを使うと seed 付きで再現できる
注意:
await fetch(...)中はクライアントに state が反映されないため、長時間の処理は UX に注意serverActionsで投げたエラーはクライアントに__action_errorとして届く。同名のactionsが既に走っている場合、その分は pending から巻き戻される- handler の並列実行に注意: Cloudflare Durable Object の input gate は ストレージ I/O 待ち中は他メッセージをブロックする が、
await fetch()のような外部 async 呼び出し中はブロックしない(公式 docs 参照)。そのため、あるserverActionshandler がfetchを待っている間に別のクライアントから来た action が並列実行され、stateを競合的に書き換える可能性がある - 対策として、
serverActionsの 副作用 (fetch等) は冪等にする か、state 変更はfetchの前 / 後の同期セクションで行う 設計を推奨する。solo モード(?roomIdなし)や emulator モード(BroadcastChannelベース)でも同様にキューイングされない
serverOnly() は非推奨
以前は actions の中に serverOnly() で wrap して置く形でした。actions の型からユニオンが 外れたため、この書き方はできません(型エラーになります)。serverActions へ移してください。
時刻は ctx.after() / gameTime() から取る
Date.now() は使いません。 state に入れる時刻は ゲーム内時刻(ゲーム開始からの経過 ms)で、Unix epoch とは 桁も意味も違います。緊急一時停止中は進まないのもゲーム内時刻だけです。
ts
// ❌ epoch が違うので締切が 5 万日後になる (TypeScript なら型で弾かれる)
actions: {
'gm.timer.set': ({ state, payload }) => {
state.timerEndsAt = Date.now() + payload.seconds * 1000;
},
},
// ✅ ctx.after(d) が「今から d ms 後」のゲーム内時刻を返す
actions: {
'gm.timer.set': ({ state, payload, ctx }) => {
state.timerEndsAt = ctx.after(payload.seconds * 1000);
},
},描画側で残り時間を出すときも gameTime() から引きます。
ts
import { gameTime, minus } from '@uzuhq/code-sdk';
const remain = Math.ceil(minus(state.timerEndsAt, gameTime()) / 1000);乱数は actions で読まない
actions の handler はサーバーとクライアント先行実行の両方で走ります。先行実行は 「サーバーと同じコードを同じ入力で走らせれば同じ結果になる」 ことが前提なので、 Math.random() を読むとその前提が崩れます。serverActions の ctx.random を使えば seed 付きで再現できます。
ズレた値は一瞬表示されたあとサーバーの ack で上書きされるため、画面が飛びます。
時刻は actions でも読んでよい
ctx.time はクロックオフセットで補正した推定値なので、先読みでもサーバーとほぼ同じ値に なります。ただし数十 ms はずれるので、時刻での分岐(「期限を過ぎていたら」の判定)は update() / deadlines 側に寄せてください。
dev では自動で検出されます。 actions の handler が先行実行中に実時刻 / 乱数を読むと、 console と dev harness の HUD に警告が出ます(本番の Flutter ホストでは計装されません)。 onState から同期で走る描画で Date.now() を読んだ場合も、epoch の取り違えとして 同じ経路で警告が出ます。
⚠️ [uzu-code] action "gm.timer.set" が先読み中に Date.now() を呼びましたE2E から window.__uzu_dev.getPredictionWarnings() で同じ記録を読めるので、 [] を assert しておくと回帰を防げます。詳しくは dev hooks を参照。
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 のみ、確定段階なし) |
serverActions handler | client では skip (サーバーで await 実行 → events は必ず配信される) | client では skip (仮想サーバーで await 実行) | client = サーバーなので 1 回だけ await 実行 |
| events の発火タイミング | actions 由来は楽観実行時に client から emit、サーバー ack 時は重複排除 (isMyAck) で skip。serverActions 由来は ack 時に必ず発火 | 同左 (仮想サーバー ack 時に重複排除) | actions 由来は同期実行の直後に発火 (serverActions の await を待たない)。serverActions 由来は完了後に発火 |
| handler 内例外時 | pending から除去して再適用 (ロールバック)、__action_error が返る | 同左 | events / state 更新を破棄 |
副作用は冪等に書く
actions の handler は online / emulator では 2 回実行されるため、副作用 (DOM 操作、外部 API 呼び出し、playSound など) は冪等になるよう書いてください。 副作用を 1 回だけ実行したい場合は serverActions に置いてサーバー側に閉じ込めるか、events 経由で sub クライアントから発火してください (events は重複排除されます)。
solo モードでは 1 回しか実行されないため、副作用の二重発火に気付かないことがあります。本番投入前に必ず emulator か online で確認してください。
サウンド再生
SDK 経由で Flutter ホストにサウンド再生をリクエストできます。
typescript
import { playSound, playBgm, stopBgm } from '@uzuhq/code-sdk';
// 効果音
playSound('sounds/clear.mp3');
// BGM
playBgm('bgm/main.mp3'); // ループ再生
stopBgm();サウンドファイルはゲームの public/ ディレクトリに配置してください。Flutter 側のホストアプリがファイルパスを解決して再生します。
接続状態の管理
run() は接続状態を自動管理します。切断時の自動再接続、メッセージバッファリングも SDK が処理します。
typescript
run({
// ...
connection: {
onConnectionStateChange(state) {
// 'connecting' → 'connected' → (切断) → 'reconnecting' → 'connected'
// → (最大試行到達) → 'disconnected'
switch (state) {
case 'connected':
hideOverlay();
break;
case 'reconnecting':
showReconnectingOverlay();
break;
case 'disconnected':
showDisconnectedOverlay();
break;
}
},
},
});