Loading...
Loading...
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
npx skill4agent add starwards/starwards starwards-colyseusClient (Browser)
↓ WebSocket connection
Room (Server)
↓ owns
State (Schema)
↓ syncs to
Client State (Mirror)modules/core/src/game-field.tsimport { 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''float64''int8''int16''int32''uint8''uint16''uint32''boolean''string'SchemaClass[SchemaClass]{map: SchemaClass}// 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;// CORRECT
@gameField([Thruster])
thrusters = new ArraySchema<Thruster>();
@gameField({map: Spaceship})
ships = new MapSchema<Spaceship>();
// WRONG
@gameField([Thruster])
thrusters: ArraySchema<Thruster>; // Not initialized!// 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)// 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;
}float32@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
}
}room.send({
type: '/Spaceship/ship-1/reactor/power',
value: 0.8
});// No code needed - JSON Pointer auto-applies to stateexport 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!AdminStatestartGamestopGameloadMapSpaceStateGameManagerSpaceManagerShipStateshipIdship-0ship-1class 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);
});// 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;
});ShipTestHarnessimport { 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();
});MultiClientDriverimport { 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();
});docs/testing/UTILITIES.md// WRONG - doesn't sync
class Shield {
strength = 1000; // No decorator
}
// CORRECT - syncs
class Shield extends Schema {
@gameField('float32') strength = 1000;
}// 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;// 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// 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
}// WRONG - exact match fails
expect(ship.speed).toBe(123.456789);
// CORRECT - close enough
expect(ship.speed).toBeCloseTo(123.46, 1);// 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
});http://localhost:2567/colyseus-monitor
Login: admin / adminconsole.log('[SERVER] Shield strength:', this.state.shield.strength);console.log('[CLIENT] Shield strength:', ship.state.shield.strength);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;
}
}// 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
});| Task | Pattern |
|---|---|
| Add synced property | |
| Nested Schema | |
| Array | |
| Map | |
| Send command (client) | |
| Listen to changes (client) | |
| Test sync | |
| Debug state | Colyseus Monitor (port 2567) |