npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@uzuhq/code-sdk

v0.8.2

Published

UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ

Readme

@uzuhq/code-sdk

UZU 上で動くゲーム・シナリオを作るための通信 SDK。マルチプレイ状態同期、サウンド再生、デバイス連携を提供します。

npm install @uzuhq/code-sdk

ゲームのビルド・配信には @uzuhq/code-cli を併用してください。

メッセージプロトコル

すべてのブリッジメッセージは { channel, type, payload } の共通構造を持つ。

| チャネル | 用途 | 説明 | | -------- | ---------------------------- | ------------------------------------------------------------- | | sdk | SDK/プラットフォームコマンド | サウンド再生、マイク制御、ルーム移動など SDK 内部のメッセージ | | game | ゲーム独自メッセージ | send() / on() で送受信するカスタムメッセージ |

send()channel: 'game' のメッセージのみを送信する。on()channel: 'game' のメッセージのみを受信し、handler には payload が直接渡される。

SDK チャネルのメッセージは playSound(), setMicEnabled() などの専用関数から自動送信される。


3 つのパラダイム

SDK は用途に応じて 3 つの API パターンを提供する。

| パラダイム | API | state 管理 | 向いているゲーム | | ---------- | --------------------- | ---------------------------------- | ---------------------------- | | Relay | init() + onRoom() | ゲーム側の責務 | 独自プロトコルが必要なゲーム | | sync() | sync() | SDK + サーバーが JSON Patch で同期 | ターン制・ボードゲーム | | run() | run() | SDK + reducer がサーバーで逐次実行 | リアルタイム・アクション全般 |

選択基準

  • 同時操作で同じリソースを奪い合う → run()(reducer が最新 state に対して逐次実行、条件保証あり)
  • 各プレイヤーが自分の領域だけ変更 → sync() で十分
  • 独自のメッセージプロトコルが必要 → Relay

初期化

init(): void

SDK を初期化し、Flutter ホスト / 親ウィンドウに準備完了を通知する。

import { init } from '@uzuhq/code-sdk';
init();
  • run() / sync() を使う場合は内部で init() が呼ばれるため不要
  • Relay パターン (onRoom()) のみ、最初に呼び出す

パターン 1: Relay

onRoom(callback: (room: RoomLike) => void): void

Room に接続されたときのコールバックを登録する。

import { init, onRoom } from '@uzuhq/code-sdk';

init();

onRoom((room) => {
  console.log('My ID:', room.myId);

  room.on('attack', (msg) => {
    console.log(`${msg.__from} sent ${msg.lines} lines`);
  });

  room.broadcast({ type: 'attack', lines: 2 });
  room.send(targetId, { type: 'whisper', text: 'hello' });
});

Room API

| メソッド/プロパティ | 型 | 説明 | | ------------------------ | ------------------------------------------------------ | -------------------------------------------------------- | | room.myId | string (readonly) | 自分の playerId | | room.broadcast(msg) | (msg: Record<string, unknown>) => void | 自分以外の全員に送信 | | room.send(id, msg) | (id: string, msg: Record<string, unknown>) => void | 特定プレイヤーに送信 | | room.on(type, handler) | (type: string, handler: (data: any) => void) => void | メッセージハンドラ登録。受信データに __from が含まれる |


パターン 2: sync()

sync<S>(config: SyncConfig<S>): void

JSON Patch ベースの状態同期を開始する。

import { sync, SERVER_TIME } from '@uzuhq/code-sdk';
import type { Seat } from '@uzuhq/code-sdk';

sync<GameState>({
  playerCount: 2,

  initialState(players: Seat[]) {
    return { board: createBoard(8), currentPlayer: players[0].id };
  },

  onState(state, myPlayerId, serverTime) {
    currentState = state;
    render();
  },

  inputs(patch, set) {
    canvas.addEventListener('click', (e) => {
      const { row, col } = getCellFromClick(e);
      // 複数操作をまとめて送信
      patch([
        { op: 'replace', path: `/board/${row}/${col}`, value: myPlayerId },
        { op: 'replace', path: '/lastMoveAt', value: SERVER_TIME },
      ]);
      // 単一値のショートカット
      set('/currentPlayer', getNextPlayer());
    });
  },

  connection: {
    onConnectionStateChange(state) {
      console.log('Connection:', state);
    },
    onPatchFailed(error) {
      console.error('Patch failed:', error);
    },
  },
});

SyncConfig

| キー | 型 | 必須 | 説明 | | -------------- | ------------------------------------------------------------ | ---- | ---------------------------- | | playerCount | number | Yes | プレイヤー数 | | initialState | (players: Seat[]) => S | Yes | 初期 state を生成 | | onState | (state: S, myPlayerId: string, serverTime: number) => void | Yes | state 更新時のコールバック | | inputs | (patch: PatchFn, set: SetFn) => void | Yes | 入力ハンドラ登録 | | events | Record<string, (data: Record<string, unknown>) => void> | No | ゲームイベントハンドラ | | connection | ConnectionCallbacks | No | 接続状態・エラーコールバック |

PatchFn / SetFn

type PatchFn = (ops: Operation[]) => void; // 複数操作をまとめて送信
type SetFn = (path: string, value: unknown) => void; // 単一パスの replace ショートカット

Operation (JSON Patch)

interface Operation {
  op: 'replace' | 'add' | 'remove';
  path: string; // JSON Pointer パス (例: '/players/alice/score')
  value?: unknown; // 値。SERVER_TIME sentinel 使用可
}

楽観的更新

sync() は送信した patch をローカルに即座に適用する。サーバーからの権威的な state を受信すると上書きする。

注意: sync() は patch の条件を検証しない。同時に同じパスを変更すると後勝ちになる。条件付きの状態遷移が必要なら run() を使う。


パターン 3: run()

run<S>(config: GameConfig<S>): void

Host-authoritative ゲームループを実行する。サーバー (GameRoom DO) が reducer を逐次実行するため、条件保証あり。

import { run } from '@uzuhq/code-sdk';

run({
  logic,
  onState(state, myPlayerId) {
    currentState = state;
    myId = myPlayerId;
  },
  inputs(sendAction) {
    document.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowUp') sendAction('move', { dx: 0, dy: -1 });
    });
  },
  events: {
    sound(data) {
      new Audio(`/sounds/${data.sound}.mp3`).play().catch(() => {});
    },
  },
  playerCount: 2,
});

GameConfig

| キー | 型 | 必須 | 説明 | | ------------------------- | -------------------------------------------------------------- | ---- | ----------------------------------- | | logic | GameLogic<S> | Yes | ゲームロジック定義 | | onState | (state: S, myPlayerId: string) => void | Yes | state 更新時のコールバック | | inputs | (sendAction: (action: string, payload: any) => void) => void | Yes | 入力ハンドラ登録 | | events | Record<string, (data: any) => void> | No | ゲームイベントハンドラ | | playerCount | number | Yes | プレイヤー数 | | onConnectionStateChange | (state: ConnectionState) => void | No | 接続状態変化コールバック | | onPatchFailed | (reason: string) => void | No | サーバー patch 適用失敗コールバック |

GameLogic

import type { GameLogic } from '@uzuhq/code-sdk';

const logic: GameLogic<MyState> = {
  setup({ players, ctx }) {
    // 初期 state を生成。ctx.random は SeededRandom、ctx.time はゲーム内時刻 (必ず 0)。
    // players は配役を受け取る参加者だけで、観測者は含まれない
    return { players: {}, items: [] };
  },

  // クライアント先読みとサーバーの 2 回走る。決定的でなければならない。
  actions: {
    move({ state, payload, playerId, ctx }) {
      // state を直接変更する(Immer 的な mutable 操作)
      state.players[playerId].x += payload.dx;
      // ctx.emit でイベント発火 (購読側は events で predict を宣言する)
      ctx.emit('sound', { sound: 'step' });
      // 時刻は ctx.after(d) を使う。Date.now() は epoch が違う (Unix ms vs ゲーム内時刻)
    },
  },

  // サーバーでのみ走る。実時刻・乱数・fetch などクライアントが再現できない処理。
  // actions と同名にすると「同じ action のサーバー側の続き」になる。
  serverActions: {
    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 }) {
    // 毎 tick 実行。ctx.tick / ctx.random / ctx.time / ctx.after / ctx.emit / ctx.playerInputs が使える
  },

  tickRate: 10, // 秒間 tick 数 (default: 0 = tick なし)
};

| キー | 型 | 必須 | 説明 | | --------------- | ---------------------------------------- | ---- | --------------------------------------------------------------- | | setup | (args: SetupArgs) => S | Yes | 初期 state を生成。args.players は配役を受け取る参加者だけ | | actions | Record<string, ActionHandler<S>> | Yes | クライアント先読み + サーバーの 2 回走る。決定的であること | | serverActions | Record<string, ServerActionHandler<S>> | No | サーバーでのみ走る。async 可。ctxtick / random が入る | | update | (args: UpdateArgs<S>) => void | Yes | 毎 tick 実行 (tickRate が 0 なら呼ばれない) | | tickRate | number | No | 秒間 tick 数 (default: 0 = tick なし) |

ハンドラの引数は 1 つのオブジェクトで、使うものだけ書けばよい。 state / payload / playerId はその呼び出しの事実、ctx は実行環境が与えるもの。

deadlines

「state のこの時刻を過ぎたらこれをする」を宣言する。サーバーが最も早い at に合わせて 自分で起き、過ぎた締切の handler を呼ぶ。

deadlines: {
  phaseTimer: {
    at: ({ state }) => state.timerEndsAt,   // null / undefined なら締切なし
    handler: ({ state, ctx }) => {
      state.phase = nextPhase(state);
      ctx.emit('phase.changed', { phase: state.phase });
    },
  },
},

| | | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | at | 締切のゲーム内時刻 (GameTime)。null / undefined で締切なし (optional chain の結果をそのまま返せる)。state だけから決まる軽い純関数にすること (state が変わるたびに呼ばれる) | | handler | サーバーでのみ走る。ctx{ time, after, random, emit } |

締切は state から導出するので、予約を張り替える処理を書かなくてよい。action が throw して state が巻き戻れば締切も一緒に巻き戻る。発火直前に at を評価し直すので、二重発火を handler 側で弾く必要も無い。

時刻をきっかけに何かを起こすなら tickRate で毎秒ポーリングせずこちらを使う。 ポーリング中は Durable Object が hibernate できない。

ゲーム内時計

時刻は Unix epoch ではない。 ctx.time はゲーム開始からの経過 ms (GameTime) で、 緊急一時停止中は進まない。Date.now() とは桁も意味も違う。

| API | 意味 | | -------------------------- | ----------------------------------------------------------------- | | ctx.time | そのハンドラが走っているゲーム内時刻。setup では必ず 0 | | ctx.after(d) | 今から d ms 後のゲーム内時刻。締切を state に置くときに使う | | gameTime() | 描画側で読む現在のゲーム内時刻 (推定値)。performance.now() 基準 | | plus(t, d) / sub(t, d) | 時刻に長さを足す / 引く | | minus(a, b) | 2 つの時刻の差 (ms) |

import { gameTime, minus } from '@uzuhq/code-sdk';

// 締切を置く
actions: {
  startPhase: ({ state, ctx }) => { state.phaseEndsAt = ctx.after(5 * 60_000); },
},

// 残り時間を描く
const remain = Math.ceil(minus(state.phaseEndsAt, gameTime()) / 1000);

GameTime は brand 型なので実行時はただの数値で、state は素の JSON のまま。ただし型の上では number と混ざらないので、Date.now() を書き込む式も endsAt - Date.now() と読む式も コンパイルが通らない。

緊急一時停止

プレイヤーが UZU メニューから全員のタイマーを止められる。シナリオは停止を知らないし、 知る必要も無い。 停止中は update() が呼ばれず、action はサーバーが弾き、締切も来ない。

演出だけを止めたいときに限り、読み取り専用で参照できる。

import { isPaused, onPauseChange } from '@uzuhq/code-sdk';

onPauseChange((paused) => (paused ? engine.stop() : engine.start()));

GameLogic からは触れない。停止を state に持ち込むと「停止中は state が変わらない」という 前提が崩れる。

serverOnly(handler)

Deprecated: serverActions に直接書く。

actions に登録する handler を「サーバーでだけ実行される」ものに変換する旧 wrapper。 serverActions フィールドが同じことを型で表現できるので、新しいコードでは使わない。

// before
actions: { notifyExternal: serverOnly(async (state) => { ... }) }
// after
serverActions: { notifyExternal: async ({ state }) => { ... } }

manifest.json(run() を使う場合)

run() を使う場合、manifest.jsonserverActionLogicPath を指定する:

{
  "id": "my-game",
  "serverActionLogicPath": "./src/logic.ts",
  "playerCount": 2,
  "build": "npm run build",
  "output": "dist"
}

SeededRandom

GameLogicsetup / update で提供される seed 付き乱数生成器。

| メソッド | 説明 | | ---------------- | ----------------------------------- | | float() | 0.0〜1.0 の浮動小数点 | | int(max) | 0〜max-1 の整数 | | pick(array) | 配列からランダムに 1 つ選択 | | shuffle(array) | 配列をシャッフル (新しい配列を返す) |


サウンド

import { playSound, playBgm, stopBgm } from '@uzuhq/code-sdk';

playSound('sounds/clear.mp3'); // 効果音
playBgm('bgm/main.mp3'); // BGM ループ再生
stopBgm(); // BGM 停止

サウンドファイルはゲームの public/ ディレクトリに配置する。


デバイス連携

setMicEnabled(enabled: boolean): void

マイクのオンオフを切り替える。

import { setMicEnabled } from '@uzuhq/code-sdk';

setMicEnabled(false); // マイクをミュート
setMicEnabled(true); // マイクをオン

changeRoom(roomId: string | null): void

ボイスチャットルームを移動する。null でデフォルトルーム(全体ルーム)に戻る。

import { changeRoom } from '@uzuhq/code-sdk';

changeRoom('room_a'); // room_a に移動(同じルームのプレイヤーとのみ音声通話可能)
changeRoom(null); // デフォルトルーム(全員が同じ音声チャンネル)に戻る
  • 初期状態ではすべてのプレイヤーがデフォルトルーム(null)に所属する
  • roomId に文字列を指定すると、そのプレイヤーは指定ルームへ移動する

onPlayersChanged(handler: (players: Record<string, PlayerVoiceState>) => void): void

プレイヤーのリアルタイム状態(音声状態)が変化したときのハンドラを登録する。

import { onPlayersChanged } from '@uzuhq/code-sdk';

onPlayersChanged((players) => {
  for (const [id, state] of Object.entries(players)) {
    // state.audioStatus: 'speaking' | 'listening' | 'muted' | 'unstable' | null
    updatePlayerUI(id, state.audioStatus);
  }
});

メッセージング

send(type: string, payload?: object): void

Flutter ホスト / 親ウィンドウへゲームチャネル (channel: 'game') のメッセージを送信する。

import { send } from '@uzuhq/code-sdk';
send('attack', { lines: 2 });
// → { channel: 'game', type: 'attack', payload: { lines: 2 } }

on(type: string, handler: (payload: any) => void): void

ゲームチャネル (channel: 'game') のメッセージを受信する。handler には payload が直接渡される。

import { on } from '@uzuhq/code-sdk';

on('attack', (payload) => {
  console.log(`Received attack with ${payload.lines} lines`);
});

:::caution on()channel: 'game' のメッセージのみを受信する。SDK チャネルのメッセージ(playersChanged 等)は onPlayersChanged() などの専用関数を使用すること。 :::

isHosted

boolean (読み取り専用)。Flutter WebView または iframe 内で動作しているか。


SERVER_TIME

サーバー時刻 sentinel 定数。Patch の value に指定すると、サーバー側で Date.now() に自動置換される。

import { SERVER_TIME } from '@uzuhq/code-sdk';
set('/meta/phaseStartedAt', SERVER_TIME);

型定義

BridgeMessage

interface BridgeMessage {
  channel: 'sdk' | 'game';
  type: string;
  payload: Record<string, unknown>;
}

Seat

roster に載る席。roster は配役を受け取る参加者だけで構成される。席種別は roster エントリ ではなく、自分の SeatKind として onState に渡る。

interface Seat {
  id: string;
  nickname: string;
  iconUrl: string;
  /** 選択済みキャラクターの ID。未選択時は undefined */
  characterId?: string;
}

SeatKind

自分の席種別。ホストが iframe URL の ?seatKind= で伝え、SDK が onState の第 3 引数 として渡す。

type SeatKind = 'player' | 'spectator' | 'admin';

観戦席・進行管理席は roster に載らないまま接続してくる。setup()players にも 現れないので「player か観測者か」は state.players の空振りで分かるが、spectatoradmin の区別は state から導けない。そこをこの値で分ける。

onState(state, myPlayerId, mySeatKind) {
  const me = state.players.find((p) => p.playerId === myPlayerId);
  if (me) return playerView(me);
  // roster に居ない = 観測者
  return mySeatKind === 'admin' ? gmView() : spectatorView();
}

自己申告なので表示の分岐にだけ使う。渡るのは自分の席種別だけで、他プレイヤーの席種別は サーバー側 handler (ActionArgs) にも渡らない。seatId の命名規約 (admin_0 等) から 判定すると、命名が変わった瞬間に静かに壊れるので避けること。

ConnectionState

type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';

ConnectionCallbacks

interface ConnectionCallbacks {
  onConnectionStateChange?: (state: ConnectionState) => void;
  onPatchFailed?: (error: string) => void;
}

PlayerVoiceState

interface PlayerVoiceState {
  audioStatus: 'speaking' | 'listening' | 'muted' | 'unstable' | null;
}

| audioStatus | 説明 | | ----------- | ------------------------ | | speaking | 発話中 | | listening | 音声接続済み・聞いている | | muted | ミュート中 | | unstable | 接続に問題あり | | null | 音声通話に未接続 |


URL パラメータ

init() / run() / sync() は以下の URL パラメータを読み取る。

| パラメータ | 説明 | | ----------- | --------------------------------------------- | | ?server= | WebSocket サーバーの URL | | ?roomId= | ルーム ID。指定するとオンラインモードになる | | ?seatId= | 自分の席 ID | | ?players= | JSON エンコードされたプレイヤーリスト | | ?__dev=N | Dev ハーネスモード (N 画面の iframe 並列表示) |

モードの自動判定

  • URL に ?roomId=xxx がない → ローカルモード (サーバー不要)
  • URL に ?roomId=xxx&server=xxx がある → オンラインモード
  • URL に ?__dev=N がある → Dev ハーネスモード

Dev Hooks (window.__uzu_dev)

外部 automation (Playwright / AI agent / E2E test) が iframe 内 state を決定論的に読み書きするための API。SDK は state shape に依存しない generic primitive だけを提供する。

Flutter native ホスト (window.FlutterHost あり) では一切 attach されない。authoritative state を持つ frame に 1 個だけ生える:

  • run() の devHarness → 親 frame だけ (page.mainFrame())
  • runLocalServerAction (ソロモード) 単独 frame → その frame
  • sync 系単独 frame → その frame
  • devHarness の 子 iframe には attach しない
window.__uzu_dev.getSnapshot(); // 現在 state (run devHarness 親 = authoritative raw)
window.__uzu_dev.getRawState(); // 生 server-side state (dev/local mode のみ)
window.__uzu_dev.playerId(); // 現在の player ID (run devHarness 親は null)

// action 送信 (run devHarness 親 frame のみ attach、それ以外は undefined)
// `as` は必須 — server-side dispatch の `from` を指定する
// Promise<void> を返す。await すると handler 完了 + broadcast 投函まで待つ
await window.__uzu_dev.send?.({ as: 'dev_0', type: 'host.phase.jump', payload: { phaseId: 'p1' } });
await window.__uzu_dev.send?.({
  as: 'dev_1',
  type: 'move.piece',
  payload: { from: 'a1', to: 'a2' },
});

// illegal move は Promise rejection になる
await expect(
  window.__uzu_dev.send({
    as: 'dev_0',
    type: 'move.piece',
    payload: {/* 相手の手番 */},
  }),
).rejects.toThrow('Not your turn');

// 生 state 書換は 3 API。用途別に使い分ける:

// 1. 全置換 (dump した state を流し込み、bug 再現など)
await window.__uzu_dev.setRawState(fullState);

// 2. RFC 7396 風 Merge Patch (object 階層の部分更新、array は atomic replace のみ)
await window.__uzu_dev.mergeRawState({ game: { timerEndsAt: null } });

// 3. RFC 6902 JSON Patch (path-based ops、array index 単体書換 OK)
await window.__uzu_dev.patchRawState([
  { op: 'replace', path: '/board/1/4', value: 99 },
  { op: 'replace', path: '/currentTurn', value: 'dev_1' },
]);

// snapshot が条件を満たすまで待つ (default 10s timeout)
await window.__uzu_dev.waitForSnapshot((s) => s.self.isReady === true);
  • state 書換 3 API: setRawState (全置換) / mergeRawState (RFC 7396 風 Merge Patch) / patchRawState (RFC 6902 JSON Patch)
    • 部分更新は mergeRawState、array 要素単体の書換は patchRawState を使う
    • mergeRawState で array field に non-array object patch を当てると throw する (array が pure object に化けるのを構造的に防止)
  • online ServerAction では 3 API すべて undefined
  • send は run devHarness 親 frame のみ。非 parent モード (runLocal / emulator / sync) では scenario 側の window.__uzu.sendAction を使う
  • await d.send(...) / await d.*RawState(...)(a) handler 完了 (b) parent state broadcast 投函 までを待つ。子 iframe canvas の paint 完了は含まない ので、screenshot / visual e2e test では await new Promise(r => requestAnimationFrame(r)) を別途挟むこと
  • phase 遷移時の field reset や markReady 等の state shape を仮定する helper は SDK には含めない。scenario 側で window.__<scene>_dev を生やして上記 primitive を組み合わせる

scenario test/tools から型補完を効かせる

scenario の test/*.ts / tools/*.ts で SDK を import せず window.__uzu_dev だけ叩く場合、tsconfig.jsontypes@uzuhq/code-sdk/dev-globals を足すと ambient で UzuDevHooks が効く:

{
  "compilerOptions": {
    "types": ["@uzuhq/code-sdk/dev-globals"],
  },
}

これで (window as any).__uzu_dev で殴らずに window.__uzu_dev?.send({ as: 'dev_0', type: '...', payload: ... }) が補完 + 型チェックされる。SDK を直接 import するモジュールでは dev-hooks.ts 側の declare global で既に型が見えているため、本 entry を types に足す必要はない。

詳細は docs/uzu_code/sdk-guide/dev-hooks.md を参照。

License

MIT