スキル一覧に戻る
starwards

starwards-colyseus

by starwards

A spaceship simulator for LARPs

39🍴 1📅 2025年12月29日
GitHubで見るManusで実行

SKILL.md


name: starwards-colyseus description: Colyseus multiplayer patterns for Starwards - @gameField decorators, state sync, JSON Pointer commands, room architecture, and avoiding common Colyseus pitfalls; state is source of truth, server authoritative version: 2025-11-04 related_skills:

  • starwards-tdd (test state sync with harnesses)
  • starwards-debugging (debug sync issues)
  • starwards-verification (verify multiplayer scenarios)
  • using-superpowers (announce skill usage)

Colyseus Multiplayer Patterns for Starwards

Overview

Starwards uses Colyseus v0.15 for real-time multiplayer state synchronization. Understanding decorators, rooms, and state sync prevents bugs.

Core principle: State is the source of truth. Commands modify state. Clients receive automatic updates.

Architecture

Client (Browser)
  ↓ WebSocket connection
Room (Server)
  ↓ owns
State (Schema)
  ↓ syncs to
Client State (Mirror)

Flow:

  1. Client sends command → Room
  2. Room modifies State
  3. Colyseus patches → Client
  4. Client state updates automatically
  5. UI reacts to state changes

The @gameField Decorator

Purpose: Marks properties for automatic Colyseus synchronization

Location: modules/core/src/game-field.ts

Usage:

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>();
}

Types:

  • 'float32' - 32-bit float (precision loss vs 64-bit number!)
  • 'float64' - 64-bit float
  • 'int8', 'int16', 'int32' - Signed integers
  • 'uint8', 'uint16', 'uint32' - Unsigned integers
  • 'boolean' - Boolean
  • 'string' - String
  • SchemaClass - Nested Schema
  • [SchemaClass] - Array of Schema
  • {map: SchemaClass} - Map of Schema

Critical @gameField Rules

Rule 1: @gameField Must Be Last Decorator

// 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;

Why: Decorators execute bottom-to-top. @gameField must wrap the final property.

Rule 2: Initialize Collections

// CORRECT
@gameField([Thruster])
thrusters = new ArraySchema<Thruster>();

@gameField({map: Spaceship})
ships = new MapSchema<Spaceship>();

// WRONG
@gameField([Thruster])
thrusters: ArraySchema<Thruster>;  // Not initialized!

Why: Colyseus needs instances, not undefined.

Rule 3: Use Correct Types

// 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)

Why: Colyseus schema types are explicit.

Rule 4: Don't Sync Everything

// 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;
}

Why: Less sync = better performance.

Float32 Precision Gotcha

Problem: JavaScript numbers are 64-bit, Colyseus float32 is 32-bit

@gameField('float32') speed = 123.456789;
console.log(speed);  // 123.46 (precision lost!)

Solution: Use toBeCloseTo() in tests

// WRONG
expect(ship.speed).toBe(123.456789);

// CORRECT
expect(ship.speed).toBeCloseTo(123.46, 1);

State vs Non-State

State (synced):

class ShipState extends Schema {
  @gameField('float32') health = 100;   // Synced
  @gameField(Reactor) reactor!: Reactor;  // Synced
}

Non-State (server only):

class ShipManager {
  updateRate = 60;                      // Not synced
  lastUpdate = Date.now();              // Not synced

  update(dt: number) {
    this.state.health -= 10 * dt;       // Modify state → syncs
  }
}

Rule: Only Schema classes with @gameField sync. Plain properties don't sync.

Commands: Client → Server

Two patterns:

1. JSON Pointer (Dynamic)

Client:

room.send({
  type: '/Spaceship/ship-1/reactor/power',
  value: 0.8
});

Server (auto-handled in ShipRoom):

// No code needed - JSON Pointer auto-applies to state

Use when: Simple property updates, GM interface, debugging

2. Typed Commands (Optimized)

Define (in core):

export const setShieldPower: StateCommand<number, ShipState, void> = {
  cmdName: 'setShieldPower',
  setValue: (state, value) => {
    state.shield.power = value;
  }
};

Server (register in room):

this.onMessage(setShieldPower.cmdName, cmdReceiver(this.manager, setShieldPower));

Client (send):

const send = cmdSender(room, setShieldPower, undefined);
send(0.5);  // Type-safe!

Use when: High-frequency commands, complex validation, type safety

Room Architecture

AdminRoom

Purpose: Game management, map selection, start/stop

State: AdminState (has SpaceState)

Clients: GM interface, admin panel

Commands: startGame, stopGame, loadMap

SpaceRoom

Purpose: Space-level gameplay, physics simulation

State: SpaceState (all space objects)

Clients: Tactical displays, overview screens

Managed by: GameManager, SpaceManager

ShipRoom (per ship)

Purpose: Individual ship control

State: ShipState (ship systems)

Clients: Ship stations (weapons, engineering, etc.)

roomId: Equals shipId (ship-0, ship-1, etc.)

Commands: JSON Pointer only (for flexibility)

State Sync Patterns

Pattern 1: Server Modifies, Clients React

Server:

class ShieldManager {
  update(dt: number) {
    // Modify state → auto-syncs
    this.state.shield.strength += rechargeRate * dt;
  }
}

Client:

// Listen for changes
ship.state.shield.onChange(() => {
  updateUI(ship.state.shield.strength);
});

Pattern 2: Client Commands, Server Validates

Client:

// User adjusts power slider
powerSlider.on('change', (value) => {
  room.send({type: '/Spaceship/ship-0/reactor/power', value});
});

Server:

// 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
});

Client:

// UI updates automatically from synced state
ship.state.reactor.listen('power', (value) => {
  powerSlider.value = value;
});

Pattern 3: Multiplayer Testing

Use 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();
});

Use 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();
});

See docs/testing/UTILITIES.md for full API.

Common Colyseus Pitfalls

Pitfall 1: Modifying State Without @gameField

// WRONG - doesn't sync
class Shield {
  strength = 1000;  // No decorator
}

// CORRECT - syncs
class Shield extends Schema {
  @gameField('float32') strength = 1000;
}

Pitfall 2: Setting Objects Instead of Properties

// 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;

Why: Colyseus tracks property changes, not object replacement.

Pitfall 3: Forgetting 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

Pitfall 4: Using State in Client-Side Logic

// 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
}

Why: Server is authoritative. Client is display only.

Pitfall 5: Float32 Precision in Tests

// WRONG - exact match fails
expect(ship.speed).toBe(123.456789);

// CORRECT - close enough
expect(ship.speed).toBeCloseTo(123.46, 1);

Pitfall 6: Not Cleaning Up Test Harness

// 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
});

Debugging Colyseus Issues

1. Colyseus Monitor

http://localhost:2567/colyseus-monitor
Login: admin / admin

See:

  • Active rooms
  • Connected clients
  • State tree (live values)

2. Chrome DevTools Network Tab

WebSocket messages:

  1. Open DevTools (F12)
  2. Network tab → WS filter
  3. Click connection
  4. See Messages: commands sent, patches received

3. State Logging

Server:

console.log('[SERVER] Shield strength:', this.state.shield.strength);

Client:

console.log('[CLIENT] Shield strength:', ship.state.shield.strength);

Compare values to find sync issues.

4. JSON Pointer Path Validation

Test paths:

const path = '/Spaceship/ship-1/shield/power';
const obj = resolveJsonPointer(state, path);
console.log('Resolved:', obj);  // Should not be undefined

Performance Considerations

Minimize Sync Frequency

// 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;
  }
}

But: Starwards updates are already optimized. Don't prematurely optimize.

Use Appropriate Types

// WRONG - wastes bandwidth
@gameField('float64') health = 100;  // 8 bytes

// CORRECT - sufficient precision
@gameField('float32') health = 100;  // 4 bytes

Batch Commands

// 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
});

But: Only if actually a bottleneck. JSON Pointer is fine for normal use.

Integration with Other Skills

  • starwards-tdd - Test state sync with harnesses
  • starwards-debugging - Debug sync issues with tools
  • starwards-verification - Verify multiplayer scenarios

Quick Reference

TaskPattern
Add synced property@gameField('type') prop = value
Nested Schema@gameField(Class) obj = new Class()
Array@gameField([Class]) arr = new ArraySchema()
Map@gameField({map: Class}) map = new MapSchema()
Send command (client)room.send({type: '/path', value})
Listen to changes (client)state.onChange(() => {})
Test syncawait harness.waitForSync()
Debug stateColyseus Monitor (port 2567)

The Bottom Line

Remember:

  1. @gameField must be last decorator
  2. State is source of truth (server authoritative)
  3. Commands go client → server, patches go server → client
  4. Use harnesses for multiplayer tests
  5. Float32 has precision loss (use toBeCloseTo)
  6. Clean up test connections (await cleanup())
  7. When in doubt, check Colyseus Monitor

スコア

総合スコア

60/100

リポジトリの品質指標に基づく評価

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新

+5
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

0/5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

+5

レビュー

💬

レビュー機能は近日公開予定です