テーマ
開発パターン
SDK は 3 つのパラダイムを提供しています。ゲームの要件に応じて選択してください。
パラダイム比較
| Relay | sync() | run() | |
|---|---|---|---|
| 概要 | 低レベルメッセージング | JSON Patch ベースの状態同期 | reducer ベースのゲームループ |
| API | init() + 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;
}
},
},
});