starwards-colyseus

Colyseus Multiplayer Patterns for Starwards

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "starwards-colyseus" with this command: npx skills add starwards/starwards/starwards-starwards-starwards-colyseus

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:

  • Client sends command → Room

  • Room modifies State

  • Colyseus patches → Client

  • Client state updates automatically

  • 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

  1. 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)

  1. Chrome DevTools Network Tab

WebSocket messages:

  • Open DevTools (F12)

  • Network tab → WS filter

  • Click connection

  • See Messages: commands sent, patches received

  1. 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.

  1. 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:

  • @gameField must be last decorator

  • State is source of truth (server authoritative)

  • Commands go client → server, patches go server → client

  • Use harnesses for multiplayer tests

  • Float32 has precision loss (use toBeCloseTo)

  • Clean up test connections (await cleanup())

  • When in doubt, check Colyseus Monitor

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

starwards-pixijs

No summary provided by upstream source.

Repository SourceNeeds Review
General

starwards-debugging

No summary provided by upstream source.

Repository SourceNeeds Review
General

using-superpowers

No summary provided by upstream source.

Repository SourceNeeds Review