Claude Code Plugins

Community-maintained marketplace

Feedback

starwards-colyseus

@starwards/starwards
35
0

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

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

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 Tue Nov 04 2025 00:00:00 GMT+0000 (Coordinated Universal Time)
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

Task Pattern
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 sync await harness.waitForSync()
Debug state Colyseus 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