What if you could combine the familiarity of Socket.io with the ease of realtime state sync from Colyseus? And what if I told you there's an approach that might be more efficient than the popular CRDT solutions everyone's talking about?
Why I Built My Own State Sync Library?
I've been working on @colyseus/schema for a few years now, and let me share why I think it deserves a spot in your toolkit alongside the many state sync libraries out there.
Don't get me wrong โ CRDTs (Conflict-free Replicated Data Types) have their place! Libraries like Yjs, Automerge, and others have enhanced collaborative applications. But here's the thing: most real-time applications don't actually need conflict resolution.
Think about it:
- Multiplayer games have authoritative servers
- Live dashboards stream from a single source of truth
- Real-time feeds come from centralized systems
For these use cases, what you really want is blazing-fast, bandwidth-efficient state synchronization โ and that's where @colyseus/schema
shines.
The Secret Sauce: Binary + Incremental = ๐
You can think of @colyseus/schema
as Protocol Buffers meets incremental encoding, featuring automatic change tracking and a callback system at the client-side.
Sounds complicated? Under the hood, it is! But it was built so the public APIs are as friendly as possible.
1. Binary Encoding (Smaller payloads)
Instead of JSON strings, @colyseus/schema
uses binary encoding (like Protocol Buffers) with numeric field identifiers. Result: 50-80% smaller payloads, faster parsing, and built-in type safety.
2. Incremental Updates (Send only what changed)
When using @colyseus/schema
, only the changes you've made to the structures are synchronized from the server to the client. Not the entire state, not diffs of JSON objects โ just the exact bytes that represent what actually changed.
// Server-side: Define your schema
class GameState extends Schema {
@type("string") status = "waiting";
@type({ map: Player }) players = new MapSchema<Player>();
}
// When you do this on the server...
gameState.status = "started";
// Only these bytes are sent to clients:
// [field_index, new_value]
// Instead of the entire gameState object!
3. Automatic Change Detection
No manual dirty checking, no complex diffing algorithms. Just modify your data structures naturally and subscribe to granular callbacks:
// Client-side: Listen to specific changes
$(gameState).players.onAdd((player, key) => {
console.log(`Player ${player.name} joined!`);
$(player).onChange(() => {
console.log(`Player ${player.name} updated!`);
})
});
$(gameState).players.onRemove((player, key) => {
console.log(`Player ${player.name} left!`)
});
$(gameState).listen("status", (value, previousValue) => {
console.log(`Status changed: ${previousValue} โ ${value}`);
});
Full example with Socket.io (It's easier than you think!)
Full source-code is available at endel/fullstack-statesync-socket.io.
Want to try it out? Here's a complete example.
The beauty of this approach is that the server operates directly on Schema structures, and changes are automatically tracked and propagated to clients:
npm install @colyseus/schema socket.io
Server side:
import { Schema, type, Encoder } from "@colyseus/schema";
import { Server } from "socket.io";
class Player extends Schema {
@type("string") name: string;
@type("number") x: number = 0;
@type("number") y: number = 0;
}
class GameState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
}
const io = new Server(3000);
const state = new GameState();
const encoder = new Encoder(state);
io.on("connection", (socket) => {
// Add new "player" to the state
const player = new Player();
state.players.set(socket.id, player);
// Send full state on connection
socket.emit("sync", Buffer.from(encoder.encodeAll()));
socket.on("move", (data) => {
player.x = data.x; // Only this change gets sent to all clients!
player.y = data.y;
});
socket.on("disconnect", () => {
console.log("Player disconnected:", socket.id);
state.players.delete(socket.id);
});
});
function broadcastChanges() {
const changes = encoder.encode();
if (changes.byteLength > 0) {
io.emit("sync", Buffer.from(changes));
}
encoder.discardChanges();
}
// This is the sweet part - only sends what changed!
setInterval(broadcastChanges, 1000 / 60);
Client side:
import { Schema, Decoder, getDecoderStateCallbacks } from "@colyseus/schema";
import io from "socket.io-client";
const socket = io("http://localhost:3000");
const state: GameState = new GameState();
const decoder = new Decoder(state);
// Decode incoming state changes
socket.on("sync", (data) => decoder.decode(data));
const $ = getDecoderStateCallbacks(decoder);
$(state).players.onAdd((player, id) => {
console.log("Player joined:", player.name);
// Add player to your game world
$(player).onChange(() => {
console.log("Player moved:", player.name, player.x, player.y);
// Update player position in your game world
});
});
$(state).players.onRemove((player, id) => {
console.log("Player left:", player.name);
// Remove player from the game world
});
Conclusion
After working with @colyseus/schema
across various real-world applications, what strikes me most is how it transforms the developer experience. The automatic synchronization means you can focus on your application logic instead of managing state updates manually โ just modify your data structures naturally, and the changes flow to your clients seamlessly. This approach keeps your codebase clean and organized, with type safety ensuring fewer runtime surprises and structured schemas that serve as living documentation of your application's state.
Join the Community!
This is open-source software under the most permissive MIT License. If you find it useful, here's how you can get involved:
- โญ Star the
@colyseus/schema
repo (it really helps!) - ๐ Report issues or suggest features
- ๐ฌ Join our Discord community
- ๐ Check out the full documentation
What's Next?
This year we've introduced StateView
, which is currently being tested by the Colyseus community. By default, the entire state is visible to all clients. However, with StateView
you can control which parts of the state are visible to each client โ perfect for scenarios where you need privacy controls or want to optimize bandwidth by sending only relevant data to specific users.
I'd love to hear your thoughts! Have you tried @colyseus/schema
? What's your go-to solution for state synchronization? Are there specific use cases you'd like me to cover in future posts?
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.