Skip to content

開発パターン

SDK は 3 つのパラダイムを提供しています。ゲームの要件に応じて選択してください。

パラダイム比較

Relaysync()run()
概要低レベルメッセージングJSON Patch ベースの状態同期reducer ベースのゲームループ
APIinit() + onRoom()sync({ initialState, onState, inputs })run({ logic, onState, inputs })
state 管理ゲーム側の責務SDK + サーバーが保存・同期SDK + reducer が管理
状態条件の保証なし (楽観更新)あり (reducer は最新 state に対して実行)
向いているゲーム独自プロトコル単純ターン制・ボードゲームリアルタイム / 競合あり / チート耐性必要

パターン 1: Relay (低レベルメッセージング)

プレイヤー間でメッセージを自由に送受信。state 管理はゲーム側の責務。

typescript
import { init, on, onRoom } from '@uzupj/uzu-sdk';

init();

on('playersChanged', (msg) => {
  for (const [id, state] of Object.entries(msg.players)) {
    updatePlayerAvatar(id, state.audioStatus);
  }
});

onRoom((room) => {
  room.on('attack', (msg) => {
    game.receiveAttack(msg.lines);
  });
  room.broadcast({ type: 'attack', lines: 2 });
});

メッセージロスに注意

Relay はサーバー側に状態を保持しません。相手がオフラインだったり再起動した場合、その間のメッセージは失われます。

パターン 2: sync() — patch ベース状態同期

JSON Patch ベースの宣言的な状態変更。全クライアント平等、楽観的更新で低レイテンシ。

typescript
import { sync, SERVER_TIME } from '@uzupj/uzu-sdk';

sync<GameState>({
  playerCount: 2,
  initialState(players) {
    return {
      board: Array.from({ length: 8 }, () => Array(8).fill(null)),
      currentPlayer: players[0].id,
    };
  },
  onState(state) {
    render(state);
  },
  inputs(patch, set) {
    canvas.addEventListener('click', (e) => {
      const { row, col } = getCellFromClick(e);
      patch([
        { op: 'replace', path: `/board/${row}/${col}`, value: myId },
        { op: 'replace', path: '/lastMoveAt', value: SERVER_TIME },
      ]);
    });
  },
});

競合を避ける 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) がゲームループを管理。チート耐性が高い。

manifest.json

json
{
  "id": "snake-battle",
  "serverActionLogicPath": "./src/logic.ts",
  "playerCount": 2,
  "build": "pnpm run build",
  "output": "dist"
}

ロジック

typescript
import type { GameLogic } from '@uzupj/uzu-sdk';

export const logic: GameLogic<MyState> = {
  setup(players, random) {
    return {
      players: Object.fromEntries(
        players.map((p) => [p.id, { x: random.int(20), y: random.int(20), score: 0 }]),
      ),
      foods: Array.from({ length: 5 }, () => ({ x: random.int(20), y: random.int(20) })),
    };
  },
  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' });
      }
    },
  },
  update(state, ctx) {
    if (ctx.tick % 50 === 0 && state.foods.length < 10) {
      state.foods.push({ x: ctx.random.int(20), y: ctx.random.int(20) });
    }
  },
  tickRate: 10,
};

エントリーポイント

typescript
import { run } from '@uzupj/uzu-sdk';
import { logic } from './logic';

run({
  logic,
  onState(state, myPlayerId) {
    currentState = state;
    myId = myPlayerId;
  },
  inputs(sendAction) {
    document.addEventListener('keydown', (e) => {
      const dirs = {
        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,
});

サーバー専用 action

外部 API 呼出や副作用の二重実行を避けたい時は serverOnly() で wrap:

typescript
import { serverOnly, type GameLogic } from "@uzupj/uzu-sdk";

actions: {
  startGame(state) {
    state.status = "started";
  },
  notifyExternal: serverOnly(async (state, payload, playerId) => {
    await fetch("https://example.com/notify", {
      method: "POST",
      body: JSON.stringify({ playerId, ...payload }),
    });
    state.notifiedAt = Date.now();
  }),
},

run() vs sync(): 状態条件の保証

要件推奨
同時操作で同じリソースを奪い合うrun()
各プレイヤーが自分の領域だけ変更 (パス競合なし)sync() で十分
条件付き状態遷移 (「まだ誰も取っていなければ取れる」等)run()
単純な値の書き込みのみsync() で十分

sync() の楽観更新は条件を保証しないため、宝箱を 2 人が同時に開けると後勝ちになる。run() はサーバー reducer で最新 state に対して逐次実行されるので、先勝ちが正しく動作する。

接続状態の管理

sync()run() は接続状態を自動管理。切断時の再接続・メッセージバッファリングも SDK が処理。

typescript
sync({
  // ...
  connection: {
    onConnectionStateChange(state) {
      switch (state) {
        case 'connected':
          hideOverlay();
          break;
        case 'reconnecting':
          showReconnectingOverlay();
          break;
        case 'disconnected':
          showDisconnectedOverlay();
          break;
      }
    },
  },
});