Skip to content

開発パターン

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.jsonserverActionLogicPath を指定してサーバーロジックをデプロイする

パラダイム比較

Relaysync()run()
概要低レベルメッセージングJSON Patch ベースの状態同期reducer ベースのゲームループ
APIinit() + onRoom()sync({ initialState, onState, inputs })run({ logic, onState, inputs })
state 管理ゲーム側の責務SDK + サーバーが保存・同期SDK + reducer が管理
ロジック実行ゲームが自由に決める各クライアントが patch を送信サーバー(GameRoom DO)が reducer を逐次実行
状態条件の保証-なし(後述)あり(reducer は最新 state に対して実行される)
向いているゲーム独自プロトコルが必要なゲーム単純なターン制・ボードゲームリアルタイム・アクション・ターン制問わず汎用
使用する接続方式RelaySyncRoomGameRoom

パターン 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 はメッセージを「その場で転送する」だけで、サーバー側に状態を保持しません。そのため、相手がオフラインだったりアプリを再起動した場合、その間に送信されたメッセージは失われます。

メッセージの到達保証や状態の永続化が必要な場合は、sync()run() の使用を検討してください。


パターン 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.jsonserverActionLogicPath を指定します。このフィールドがサーバーで実行するロジックのエントリポイントを示し、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 も使えるため fetchawait できます。

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クライアント + 仮想サーバー (BroadcastChannelplayers[0] の iframe で起動)クライアント単体 (state も同居)
sendAction() 直後の挙動standard handler を同期で先行実行 → onState() 同期発火同左同左
standard handler の実行回数2 回 (client 楽観 + server 確定)2 回 (client 楽観 + 仮想サーバー確定)1 回 (client のみ、確定段階なし)
serverOnly() handlerclient では 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;
      }
    },
  },
});