Smithery Logo
MCPsSkillsDocsPricing
Login
Smithery Logo

Accelerating the Agent Economy

Resources

DocumentationPrivacy PolicySystem Status

Company

PricingAboutBlog

Connect

© 2026 Smithery. All rights reserved.

    starwards

    starwards-colyseus

    starwards/starwards-colyseus
    Coding
    39
    7 installs

    About

    SKILL.md

    Install

    Install via Skills CLI

    or add to your agent
    • Claude Code
      Claude Code
    • Codex
      Codex
    • OpenClaw
      OpenClaw
    • Cursor
      Cursor
    • Amp
      Amp
    • GitHub Copilot
      GitHub Copilot
    • Gemini CLI
      Gemini CLI
    • Kilo Code
      Kilo Code
    • Junie
      Junie
    • Replit
      Replit
    • Windsurf
      Windsurf
    • Cline
      Cline
    • Continue
      Continue
    • OpenCode
      OpenCode
    • OpenHands
      OpenHands
    • Roo Code
      Roo Code
    • Augment
      Augment
    • Goose
      Goose
    • Trae
      Trae
    • Zencoder
      Zencoder
    • Antigravity
      Antigravity
    ├─
    ├─
    └─

    About

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

    SKILL.md

    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
    Recommended Servers
    Vercel Grep
    Vercel Grep
    Unicorn or Bust
    Unicorn or Bust
    Svelte
    Svelte
    Repository
    starwards/starwards
    Files