Colyseus 0.18 is here!
Built-in client-side prediction and lag compensation, request/response messaging, Schema 5.0, and the new @colyseus/database and @colyseus/admin packages.
Iโm thrilled to share version 0.18 of Colyseus is here! ๐
0.18 is intended to be the last release before 1.0, and it brings a lot of new features and improvements. The focus was on fast-paced games and real-time action, but the changes benefit all genres.
Key highlights of 0.18 include built-in client-side prediction and lag compensation, a ~2.4ร faster @colyseus/schema encoder, and many reliability fixes to StateView and ArraySchema.
0.18 also introduces @colyseus/database, persistence on Drizzle ORM (PostgreSQL or SQLite), and @colyseus/admin, an operations console on top of it.
Prediction demos and playground ๐
The fastest way to see what 0.18 changes is to play it. While building the new prediction APIs, I kept five demos running alongside them. Each one validates a different piece of the stack.
Air Hockey covers composite prediction, ColyStrike (FPS) covers lag compensation, and the MOBA runs a shared deterministic simulation. The Platformer tests dead-reckoned moving platforms, and Karts covers rollback prediction with hold-to-drift mini-turbos:
Give the demos a try!
The source code of all five demos is available exclusively to GitHub sponsors. Sponsoring is the most direct way to support the framework, and the premium demos will keep growing!
The Prediction Playground, by contrast, is fully open source. It isolates each technique in a small lab: reconciliation, the interpolation modes, dead reckoning, lag compensation, optimistic events, predicted spawns, and more. All twelve labs share one server, with clients for multiple platforms (Web, Unity, Godot, Defold, etc.).
Client-side prediction & lag compensation ๐ฎ
Fast-paced games canโt wait for the network. If your character only moves after a round-trip to the server, controls feel sluggish. But if the client simply moves itself, the server is no longer in charge and cheating becomes trivial.
0.18 gives you both. The server stays authoritative and runs a fixed-rate simulation. The client predicts against it, and hits are resolved against what the shooter actually saw. The framework runs the prediction, reconciliation, and rewind loops. You supply the step function.
On the server, three calls:
import { Room } from "colyseus";import { schema, t } from "@colyseus/schema";import { applyInput, LEVEL } from "./shared/applyInput"; // SAME module the client imports
// shared/MoveInput.ts: the only shape this room accepts, imported by both sides.export const MoveInput = schema({ moveX: t.number(), moveY: t.number() }, "MoveInput");
class GameRoom extends Room<{ input: MoveInput }> { state = new GameState();
// One buffer per session; hacked values are clamped on arrival. inputs = this.defineInput(MoveInput, { bufferMaxSize: 64, sanitize: { moveX: (v) => Math.max(-1, Math.min(1, v)) }, });
// Records attached entities' positions (auto, on each broadcast). rewind = this.allowRewindState({ maxRewindMs: 500 });
onCreate() { this.rewind.attachAll(this.state.players, { fields: ["x", "y"] }); // Fixed-step loop @ 30 Hz: advertises the rate to predicting clients. this.setFixedTimestep((ctx) => this.step(ctx), 30); }
step(ctx) { for (const [sid, p] of this.state.players) { const cmd = this.inputs.get(sid).next(); // one input per tick if (cmd) applyInput(p, cmd, LEVEL, ctx.dt); } }}defineInput() declares what a client may send and buffers it per session. A sanitize map clamps hacked values before your simulation reads them. setFixedTimestep() runs your step at a constant delta time. The tick rate is advertised to clients through the join handshake.
On the client:
import { Predict } from "@colyseus/sdk";import { MoveInput } from "./shared/MoveInput";import { applyInput, LEVEL } from "./shared/applyInput"; // SAME function the server runs
const predict = Predict.get(room, { mode: "lerp", delay: 100 });
// Remote players: render 100ms in the past, interpolated between snapshots.predict.attachAll("players", { mode: "lerp", fields: ["x", "y"] });
// Local player: the input handle is the ONLY thing that stages + sends.const input = room.input({ type: MoveInput });const self = room.state.players.get(room.sessionId);const me = predict.reconciler(self, { input, step: (ctx, s, cmd) => applyInput(s, cmd, LEVEL, ctx.dt), smoothMs: 65,});
function frame(now) { const n = predict.tick(now); // fixed input steps due this frame for (let i = 0; i < n; i++) { input.data.moveX = moveX(); input.send(); // transmit + buffer, the reconciler observes } for (const [, p] of room.state.players) { draw(predict.value(p, "x"), predict.value(p, "y")); // ONE read idiom, local + remote } requestAnimationFrame(frame);}The important line is the import. applyInput is the same function the server runs, so you write your movement once, in shared code. predict.reconciler handles the rollback. When authoritative state arrives, it rewinds your entity, replays the inputs the server hasnโt acknowledged yet, and smooths out the mismatch. attachAll interpolates everyone else a hundred milliseconds in the past. predict.value() reads local and remote entities the same way.
Lag compensation is the hardest part to build yourself. allowRewindState() records where every attached entity was on each broadcast. rewind.lastSeenBy(sessionId) then gives your hit test the world as that client last saw it, clamped to your maxRewindMs window. A player at 250ms of latency aims at a target that is a quarter of a second old, and the shot still lands.
Add import "@colyseus/sdk/debug" for a panel with per-reconciler drift telemetry and a latency simulator.
See Netcode ๐ for more.
Schema 5.0 โก
@colyseus/schema 5.0 is a big part of this release. The rewritten encoder performs about 2.4ร faster than 4.0, so a server heavy on state synchronization can handle more CCU just by upgrading.
Schema 5.0 introduces a new decorator-free syntax for defining state. The old decorator syntax will continue to be supported, but the new syntax is more explicit and avoids some of the pitfalls of decorators in TypeScript.
// 0.17 and still supported // 0.18: the new defaultclass Player extends Schema { export const Player = schema({ @type("number") x: number = 0; x: t.number().default(0), @type("number") y: number = 0; y: t.number().default(0),} }, "Player");The new syntax also drops the experimentalDecorators and useDefineForClassFields tsconfig requirements, which simplifies tooling and removes a class of compatibility issues.
Two new delivery modifiers control when a field reaches a client. .patchOnly() keeps one-frame impulses, like a hit flash, out of the full state sync. .fullStateOnly() sends map layout and spawn points once on join, and never patches them again.
Quantized floats shrink the fields that change every tick. A float inside a known range doesnโt need four bytes. t.quantized() maps the range onto an 8-, 16- or 32-bit integer on the wire, and your code still reads and writes a plain number. t.angle() is the ready-made preset for rotations, which wrap rather than clamp:
const Input = schema({ yaw: t.angle(), // 2 bytes, wraps pitch: t.quantized({ min: -Math.PI/2, max: Math.PI/2 }), // 2 bytes, clamps throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte}, "Input");StateView also gained a standing subscription. view.subscribe(collection) covers present and future contents, whereas view.add() is one-shot. Pair it with streaming collections (t.stream(), experimental) to send large collections in slices across ticks, nearest-first per client.
See Schema ๐ for more.
Request/response messaging ๐
Clients can now await a reply from a message handler. You no longer need to pair a request message with a reply message yourself:
const profile = await room.request("get-profile", { userId: 42 });On the server, the same messages handlers serve both fire-and-forget sends and requests. Return a value to answer, or use the new optional ctx argument to reject with a typed reason:
messages = { "get-profile": async (client, { userId }) => { return await db.profiles.findById(userId); // becomes the client's response }, "buy-item": (client, { itemId }, ctx) => { const item = shop.get(itemId); if (!item) return ctx.reject("unknown-item"); // rejects the client's promise return { balance: item.buy(client) }; },}Existing two-argument handlers are unaffected. Request/response is currently available on the JavaScript/TypeScript SDK.
See Request/Response ๐ for more.
Database & Admin ๐พ
Colyseus never had persistence built in, so every project assembled its own. @colyseus/database is a first step toward changing that: a GameDatabase on Drizzle ORM, running on SQLite or PostgreSQL, wired into the server with one option. Expect it to evolve with your feedback.
import { defineServer } from "colyseus";import { GameDatabase } from "@colyseus/database";
export const db = new GameDatabase({ connectionString: process.env.DATABASE_URL,});
export default defineServer({ database: db, // ...rooms, routes, etc.});The package ships with the user store behind @colyseus/auth, versioned cloud saves with optimistic locking, leaderboards with seasons and aroundMe() queries, hot-reloadable live configs, analytics, moderation tools, and an audit log. The same connection can also serve as your matchmaking driver.
@colyseus/admin (beta) is the operations console on top of it. You get CRUD over your tables, a live room inspector across every process, dashboard widgets, and role-based access.

See Database ๐ and Admin ๐ for more.
Room plugins ๐
Rooms can now be composed from plugins. A plugin contributes message handlers, lifecycle hooks, and public methods. definePlugins() turns the list into a typed record:
import { Room, definePlugins } from "colyseus";import { IdleKickPlugin } from "colyseus/plugins/idle-kick";
export class MyRoom extends Room { plugins = definePlugins([ new IdleKickPlugin({ timeoutMs: 60_000 }), ]);}Built-ins ship with the colyseus package: IdleKickPlugin, WebRTCPlugin (peer-to-peer signaling), UniqueSessionPlugin (one session per user), and TrackUserSessionsPlugin. Separately, the new @colyseus/geoip package resolves the clientโs country at auth time.
See Room Plugins ๐ for more.
More new features & improvements ๐
- Reconnect resync: on rejoin, the SDK reconciles the existing decoded state instead of decoding on top of it. Entries removed while you were offline are pruned. Object identity and callbacks are preserved. (docs)
beforeUpgrade: inspect the request before the WebSocket handshake, and return aResponseto refuse it. (docs)- New SDKs (beta): a shared Native SDK in C, with Godot, GameMaker, and Flutter integrations, plus a MonoGame integration.
@colyseus/react:usePredict,useInput,useReconcilerand more wrap the netcode stack as hooks.setTimestep():setSimulationInterval()is nowsetTimestep(). The old name still works.basicAuth(): password-protect HTTP routes, the monitor and the playground. (docs)- Smaller SDK: the browser bundle is minified and tree-shaken. ~58 KB gzip, down from ~191 KB.
- Playground: CPU-profiling tab, grouped endpoint sidebar, and
room.request()in the connection inspector. create-colyseus-apppresets:minimal,realtime-action,turn-based, plus flags for non-interactive setup. (docs)
Breaking changes ๐จ
Migrating from version 0.17 to 0.18 shouldnโt take long. Hereโs an overview of the breaking changes:
- A
Schemanow holds at most 63 fields (was 64). The last slot was always unsafe, so 0.18 throws at startup instead of desyncing in production. setMetadata()andsetMatchmaking({ metadata })now replace metadata instead of merging. Spreadthis.metadatato keep the old behavior.client.idwas removed. Useclient.sessionId.@colyseus/fossil-delta-serializerwas removed.- Playground data endpoints return 404 in production unless you pass a
useguard.
One change needs action from your users:
Existing email/password users must reset their password.
@colyseus/authships a new password hasher with per-password salts, and pre-0.18 hashes cannot be verified by it. KeepAUTH_SALTset during the transition so the legacy hasher stays available. Send users through the forgot-password flow, then drop the variable. New projects can skip it entirely.
See Migrating to 0.18 ๐ for more details.
Thanks to the community ๐
0.18 exists because people filed careful reports and sent patches. A special thank you to @bsharma-imperium, who started the new Native SDK and wrote much of its foundation. @FTWinston contributed several StateView improvements, plus much of the React hooks. @pierroo and @zahmad12 reported the Android bugs in the Godot build. @LePetitPrince-4 discovered the password-hashing issue in @colyseus/auth, now documented in the migration guide.
Pull requests also came from @Hoodgail, @anaibol, @kuoder, @lkinasiewicz, @Andrek25, @JoaoCnh, @darkdi and @Br1an67. Each of these people reported a bug or requested a feature that 0.18 delivers: @ehart004, @serjek, @thedomeffm, @chungweileong94, @igasmi, @sarpaslan, @krabas, @NotRustyBot, @ColaFanta, @trueicecold, @TJEvans, @neizzz, @mikkas70, @hunkydoryrepair, @cskinfill, @ArthurVanRemoortel, @mqllin, @paulocoutinhox and @beemdvp.
I also receive plenty of reports and suggestions on Discord or via DM. Those messages are just as valuable, but much harder to track. Thank you to everyone who reported something there!
The future: version 1.0 and beyond ๐
Colyseus is independent and open-source. A solo indie developer maintains it, driven by the feedback, bug reports, and contributions of a passionate community. There is no venture capital or corporate backing behind this project, only a shared commitment to building the best multiplayer framework together. Your sponsorship directly funds new features, bug fixes, and the path to version 1.0.
If Colyseus powers your game or project, consider becoming a sponsor to help keep the framework thriving. Every contribution, big or small, makes a real difference. It keeps Colyseus free, independent, and actively maintained for everyone.
๐ Special shoutout to Scorewarrior, Pixels.xyz, Petr Kharitonov, Bloxd, Poki, Wavedash, and all our supporters ๐








