starwards-colyseus について
ルーム、状態、クライアントコマンドを扱うStarwards開発者向けの、Colyseus同期スキルです。同期ルールやよくある問題に加え、状態複製のテストとデバッグ方法を説明します。
主な機能
- 01@gameFieldによる同期状態の定義
- 02ルームとクライアントコマンドの構成
- 03ネストしたコレクションと状態更新の処理
- 04型の精度と同期時の注意点
- 05同期テストとデバッグ手順
活用シーン
- 01マルチプレイヤー同期状態の追加や変更
- 02クライアント状態が更新されない問題の調査
- 03複数クライアント間の状態一致をテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ starwards/ starwards --skill starwards-colyseusインストール後、Claude Code で/starwards-colyseusを入力して読み込まれたか確認します。
詳細
Starwards 向け Colyseus マルチプレイヤーパターン
概要
Starwards は、リアルタイムのマルチプレイヤー状態同期に Colyseus v0.15 を使用します。デコレーター、ルーム、状態同期について理解しておくと、バグを防げます。
基本原則: 状態が唯一の信頼できる情報源です。コマンドは状態を変更します。クライアントは自動的に更新を受け取ります。
アーキテクチャ
Client (Browser)
↓ WebSocket connection
Room (Server)
↓ owns
State (Schema)
↓ syncs to
Client State (Mirror)
フロー:
- クライアントがコマンドを送信 → ルーム
- ルームが状態を変更
- Colyseus がパッチを送信 → クライアント
- クライアントの状態が自動的に更新
- UI が状態の変更に反応
@gameField デコレーター
目的: Colyseus による自動同期の対象としてプロパティを指定します
場所: modules/core/src/game-field.ts
使用方法:
import { gameField } from '../game-field';
class Shield extends SystemState {
// Primitive types
@gameField('float32') strength = 1000;
@gameField('float32') power = 1.0;
@gameField('boolean') broken = false;
// Nested Schema
@gameField(ShieldDesign) design = new ShieldDesign();
// Arrays
@gameField([Emitter]) emitters = new ArraySchema<Emitter>();
// Maps
@gameField({ map: Target }) targets = new MapSchema<Target>();
}
型:
'float32'- 32 ビット浮動小数点数(64 ビット数値より精度が低くなります!)'float64'- 64 ビット浮動小数点数'int8'、'int16'、'int32'- 符号付き整数'uint8'、'uint16'、'uint32'- 符号なし整数'boolean'- ブール値'string'- 文字列SchemaClass- ネストされた Schema[SchemaClass]- Schema の配列{map: SchemaClass}- Schema のマップ
@gameField に関する重要なルール
ルール 1:@gameField は最後のデコレーターにする
// CORRECT order
@range([0, 1]) // 1st
@tweakable('number') // 2nd
@gameField('float32') // 3rd - LAST
power = 1.0;
// WRONG order
@gameField('float32') // Can't be first
@range([0, 1])
power = 1.0;
理由: デコレーターは下から上へ実行されます。@gameField は最終的なプロパティをラップする必要があります。
ルール 2:コレクションを初期化する
// CORRECT
@gameField([Thruster])
thrusters = new ArraySchema<Thruster>();
@gameField({map: Spaceship})
ships = new MapSchema<Spaceship>();
// WRONG
@gameField([Thruster])
thrusters: ArraySchema<Thruster>; // Not initialized!
理由: Colyseus は undefined ではなく、インスタンスを必要とします。
ルール 3:正しい型を使う
// CORRECT
@gameField('float32') speed = 0; // 32-bit float
@gameField('int16') count = 0; // 16-bit int
// WRONG
@gameField('number') speed = 0; // No 'number' type
@gameField('float') speed = 0; // No 'float' type (use float32/float64)
理由: Colyseus のスキーマ型は明示的に指定します。
ルール 4:すべてを同期しない
// Only sync state that clients need
@gameField('float32') health = 100; // ✅ Clients need this
@gameField('float32') damage = 10; // ❌ Internal calculation, don't sync
// Derive internally
get damage() {
return this.weapon.baseDamage * this.effectiveness;
}
理由: 同期が少ないほど、パフォーマンスが向上します。
2 階層のネストされた ArraySchema
別の ArraySchema<Parent> のアイテム内にある ArraySchema<Child> は正しく同期されます。2 階層の深さに特別な対応は不要で、中間階層のスキーマに通常の @gameField([Child]) を指定するだけです。実例:modules/core/src/ship/armor.ts の Armor.armorPlates[i].layers[j].health。
- colyseus-events は両方のインデックスを含むリーフイベント(例:
/armor/armorPlates/3/layers/0/health)を発行し、EventEmitter2 のワイルドカードリスナー(/armor/armorPlates/*/layers/*/health、区切り文字'/')がこれに一致します。 wireEventsの後に新しいネストされた子を追加すると、自動的にイベントが配線されます。
modules/core/test/armor-layers-sync.spec.ts でテストされています。
Float32 の精度に関する注意点
問題: JavaScript の数値は 64 ビットですが、Colyseus の float32 は 32 ビットです
@gameField('float32') speed = 123.456789;
console.log(speed); // 123.46 (precision lost!)
解決策: テストでは toBeCloseTo() を使います
// WRONG
expect(ship.speed).toBe(123.456789);
// CORRECT
expect(ship.speed).toBeCloseTo(123.46, 1);
状態と非状態
状態(同期対象):
class ShipState extends Schema {
@gameField('float32') health = 100; // Synced
@gameField(Reactor) reactor!: Reactor; // Synced
}
非状態(サーバーのみ):
class ShipManager {
updateRate = 60; // Not synced
lastUpdate = Date.now(); // Not synced
update(dt: number) {
this.state.health -= 10 * dt; // Modify state → syncs
}
}
ルール: @gameField を持つ Schema クラスのみが同期されます。通常のプロパティは同期されません。
コマンド:クライアント → サーバー
次の 2 つのパターンがあります。
1. JSON Pointer(動的)
クライアント:
room.send({
type: '/Spaceship/ship-1/reactor/power',
value: 0.8
});
サーバー(ShipRoom で自動処理):
// No code needed - JSON Pointer auto-applies to state
使用する場面: 単純なプロパティ更新、GM インターフェース、デバッグ
2. 型付きコマンド(最適化)
定義(core 内):
export const setShieldPower: StateCommand<number, ShipState, void> = {
cmdName: 'setShieldPower',
setValue: (state, value) => {
state.shield.power = value;
}
};
サーバー(ルームに登録):
this.onMessage(setShieldPower.cmdName, cmdReceiver(this.manager, setShieldPower));
クライアント(送信):
const send = cmdSender(room, setShieldPower, undefined);
send(0.5); // Type-safe!
使用する場面: 高頻度のコマンド、複雑な検証、型安全性
ルームのアーキテクチャ
AdminRoom
目的: ゲーム管理、マップ選択、開始/停止
状態: AdminState(SpaceState を持ちます)
クライアント: GM インターフェース、管理パネル
コマンド: startGame、stopGame、loadMap
SpaceRoom
目的: 宇宙レベルのゲームプレイ、物理シミュレーション
状態: SpaceState(すべての宇宙オブジェクト)
クライアント: 戦術ディスプレイ、概要画面
管理者: GameManager、SpaceManager
ShipRoom(船ごと)
目的: 個々の船の操作
状態: ShipState(船のシステム)
クライアント: 船の各ステーション(兵器、機関など)
roomId: shipId と同じです(ship-0、ship-1 など)
コマンド: JSON Pointer のみ(柔軟性のため)
状態同期のパターン
パターン 1:サーバーが変更し、クライアントが反応
サーバー:
class ShieldManager {
update(dt: number) {
// Modify state → auto-syncs
this.state.shield.strength += rechargeRate * dt;
}
}
クライアント:
// Listen for changes
ship.state.shield.onChange(() => {
updateUI(ship.state.shield.strength);
});
パターン 2:クライアントがコマンドを送信し、サーバーが検証
クライアント:
// User adjusts power slider
powerSlider.on('change', (value) => {
room.send({type: '/Spaceship/ship-0/reactor/power', value});
});
サーバー:
// Receives command, validates, applies
this.onMessage((client, message) => {
const value = clamp(0, 1, message.value); // Validate
applyJsonPointer(this.state, message.type, value); // Apply
// Auto-syncs to all clients
});
クライアント:
// UI updates automatically from synced state
ship.state.reactor.listen('power', (value) => {
powerSlider.value = value;
});
パターン 3:マルチプレイヤーのテスト
ShipTestHarness を使う:
import { ShipTestHarness } from './ship-test-harness';
test('client receives server state updates', async () => {
const harness = new ShipTestHarness();
await harness.connect();
// Server modifies
harness.shipManager.state.shield.strength = 750;
// Wait for sync
await harness.waitForSync();
// Client receives
expect(harness.shipDriver.state.shield.strength).toBe(750);
await harness.cleanup();
});
MultiClientDriver を使う:
import { MultiClientDriver } from '@starwards/server/test/multi-client-driver';
test('multiple clients see same state', async () => {
const driver = new MultiClientDriver();
await driver.start();
const [c1, c2] = await Promise.all([
driver.joinShip('ship-1'),
driver.joinShip('ship-1')
]);
// Modify server state
driver.getShipManager('ship-1').state.shield.strength = 800;
await driver.waitForSync();
// Both clients updated
expect(c1.state.shield.strength).toBe(800);
expect(c2.state.shield.strength).toBe(800);
await driver.cleanup();
});
API の全容については、docs/testing/UTILITIES.md を参照してください。
Colyseus でよくある落とし穴
落とし穴 1:@gameField を使わずに状態を変更する
// WRONG - doesn't sync
class Shield {
strength = 1000; // No decorator
}
// CORRECT - syncs
class Shield extends Schema {
@gameField('float32') strength = 1000;
}
落とし穴 2:プロパティではなくオブジェクトを設定する
// WRONG - breaks references
state.velocity = {x: 10, y: 0};
// CORRECT - update properties
state.velocity.setValue({x: 10, y: 0});
// Or:
state.velocity.x = 10;
state.velocity.y = 0;
理由: Colyseus はオブジェクトの置き換えではなく、プロパティの変更を追跡します。
落とし穴 3:await harness.waitForSync() を忘れる
// WRONG - client not updated yet
harness.shipManager.state.health = 50;
expect(harness.shipDriver.state.health).toBe(50); // FAILS
// CORRECT - wait for replication
harness.shipManager.state.health = 50;
await harness.waitForSync();
expect(harness.shipDriver.state.health).toBe(50); // PASSES
落とし穴 4:クライアント側のロジックで状態を使う
// WRONG - client shouldn't have business logic
if (ship.state.health < 50) {
ship.state.broken = true; // Don't modify from client
}
// CORRECT - send command, server decides
if (ship.state.health < 50) {
room.send({type: 'checkBroken'}); // Server validates & applies
}
理由: サーバーが権威を持ちます。クライアントは表示専用です。
落とし穴 5:テストでの Float32 の精度
// WRONG - exact match fails
expect(ship.speed).toBe(123.456789);
// CORRECT - close enough
expect(ship.speed).toBeCloseTo(123.46, 1);
落とし穴 6:テストハーネスをクリーンアップしない
// WRONG - leaves connections open
test('something', async () => {
const harness = new ShipTestHarness();
await harness.connect();
// Test code
}); // MISSING cleanup()!
// CORRECT
test('something', async () => {
const harness = new ShipTestHarness();
await harness.connect();
// Test code
await harness.cleanup(); // Clean up
});
Colyseus の問題をデバッグする
1. Colyseus Monitor
http://localhost:2567/colyseus-monitor
Login: admin / admin
確認できる項目:
- アクティブなルーム
- 接続中のクライアント
- 状態ツリー(ライブ値)
2. Chrome DevTools の Network タブ
WebSocket メッセージ:
- DevTools を開きます(F12)
- Network タブ → WS フィルター
- 接続をクリックします
- Messages を確認します:送信されたコマンド、受信したパッチ
3. 状態のログ記録
サーバー:
console.log('[SERVER] Shield strength:', this.state.shield.strength);
クライアント:
console.log('[CLIENT] Shield strength:', ship.state.shield.strength);
値を比較して同期の問題を特定します。
4. JSON Pointer のパス検証
パスをテスト:
const path = '/Spaceship/ship-1/shield/power';
const obj = resolveJsonPointer(state, path);
console.log('Resolved:', obj); // Should not be undefined
パフォーマンスに関する考慮事項
同期頻度を最小限に抑える
// WRONG - syncs 60 times/sec
update(dt: number) {
this.state.position.x += velocity.x * dt;
this.state.position.y += velocity.y * dt;
}
// BETTER - sync only when significant change
update(dt: number) {
const newX = this.state.position.x + velocity.x * dt;
const newY = this.state.position.y + velocity.y * dt;
if (Math.abs(newX - this.state.position.x) > 0.1) {
this.state.position.x = newX;
}
if (Math.abs(newY - this.state.position.y) > 0.1) {
this.state.position.y = newY;
}
}
ただし: Starwards の更新はすでに最適化されています。時期尚早な最適化は避けてください。
適切な型を使う
// WRONG - wastes bandwidth
@gameField('float64') health = 100; // 8 bytes
// CORRECT - sufficient precision
@gameField('float32') health = 100; // 4 bytes
コマンドをまとめる
// WRONG - multiple round trips
room.send({type: '/Spaceship/ship-0/reactor/power', value: 0.8});
room.send({type: '/Spaceship/ship-0/thrusters/0/enabled', value: true});
room.send({type: '/Spaceship/ship-0/thrusters/1/enabled', value: true});
// BETTER - single batch command
room.send('batchUpdate', {
'/reactor/power': 0.8,
'/thrusters/0/enabled': true,
'/thrusters/1/enabled': true
});
ただし: 実際にボトルネックになっている場合に限ります。通常の使用では JSON Pointer で問題ありません。
他のスキルとの連携
- starwards-tdd - ハーネスを使って状態の同期をテストします
- starwards-debugging - ツールを使って同期の問題をデバッグします
- starwards-verification - マルチプレイヤーのシナリオを検証します
クイックリファレンス
| タスク | パターン |
|---|---|
| 同期するプロパティを追加する | @gameField('type') prop = value |
| ネストされた Schema | @gameField(Class) obj = new Class() |
| 配列 | @gameField([Class]) arr = new ArraySchema() |
| マップ | @gameField({map: Class}) map = new MapSchema() |
| コマンドを送信する(クライアント) | room.send({type: '/path', value}) |
| 変更をリッスンする(クライアント) | state.onChange(() => {}) |
| 同期をテストする | await harness.waitForSync() |
| 状態をデバッグする | Colyseus Monitor(ポート 2567) |
要点
覚えておくこと:
- @gameField は最後のデコレーターにする必要があります
- 状態が唯一の信頼できる情報源です(サーバーが権威を持ちます)
- コマンドはクライアント → サーバー、パッチはサーバー → クライアントに送られます
- マルチプレイヤーのテストにはハーネスを使います
- Float32 では精度が失われます(toBeCloseTo を使います)
- テスト接続をクリーンアップします(await cleanup())
- 判断に迷ったら Colyseus Monitor を確認します