Skip to content

開発パターン ​

ゲームのロジックはサーバーで動きます。クライアントは 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 参照)。そのため、ある serverActions handler が 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 handlerclient では 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;
      }
    },
  },
});