Write Colyseus room logic in Lua 5.3, executed by
Fengari (a pure-JS Lua VM) inside the
Node.js process. No native bindings, no WASM — the Lua heap is the JS heap,
so Lua reads and writes real @colyseus/schema instances directly, and Lua
functions serve as real JS callbacks.
Status: works end-to-end. Schemas, lifecycle, messages, simulation ticks,
fixed-timestep input channels, async hooks (colyseus.await), safe timers,
and hot reload are all functional and covered by tests against real SDK
clients over WebSockets.
-- rooms/my_room.lua
local colyseus = require "colyseus"
local schema, t = colyseus.schema, colyseus.t
local Player = schema("Player", {
x = t.number(0),
y = t.number(0),
vx = t.number(0):noSync(),
vy = t.number(0):noSync(),
})
local State = schema("State", {
players = t.map(Player),
})
local MyRoom = colyseus.room {
max_clients = 8,
timestep = 16,
}
function MyRoom:on_create(options)
self.state = State()
end
function MyRoom:on_join(client, options)
self.state.players:set(client.sessionId, Player { x = 100, y = 100 })
end
function MyRoom:on_leave(client, code)
self.state.players:delete(client.sessionId)
end
MyRoom.messages = {
move = function(self, client, message)
local p = self.state.players:get(client.sessionId)
if p then
p.vx = message.x or 0
p.vy = message.y or 0
end
end,
}
function MyRoom:on_timestep(dt)
for _, p in colyseus.pairs(self.state.players) do
p.x = p.x + p.vx * dt * 0.3
p.y = p.y + p.vy * dt * 0.3
end
end
return MyRoom// server.ts — the entire JS entry
const vm = createLuaVM({ roomsDir: "./rooms" });
const server = defineServer({
rooms: discoverLuaRooms(vm, "./rooms"), // rooms/<name>.lua → room "<name>"
});
watchLuaRooms(vm, "./rooms"); // live-edit hooks while running
await server.listen(2567);
startLuaRepl(vm); // rooms(), room(id).state, any Lua — live at the terminalRequires Node ≥ 22.18 (native TS type-stripping). Colyseus packages come from
npm (@colyseus/core 0.18.1+).
pnpm install
npm start # Lua-powered server on :2567
npm run demo:client # two SDK clients moving around
npm test # full test suite (node --test)
npm run bench # on_timestep micro-benchmark
npm run build # emit dist/ (JS + .d.ts + runtime lua) for packaging
The repo is structured as the @colyseus/lua package: peerDependencies on
@colyseus/core ^0.18 and @colyseus/schema ^5 (satisfied by the published
dev-dependencies), npm run build emits dist/ with declarations and the
bundled runtime/colyseus.lua, and exports points consumers at the build.
"private": true stays on until it's ready to publish.
Everything the bridge owns is snake_case; JS objects reached through
interop keep their JS names (client.sessionId, self:broadcast(...),
builder :noSync()). self in every hook is the live JS Room instance —
use colon-calls for methods so this binds correctly.
| API | Notes |
|---|---|
colyseus.schema(name, fields) |
Runtime schema class. Callable: Player { x = 1 }. Raw ctor at .class. Functions in fields become methods (initialize runs at construction); each receives self first. Subclass via Player:extend("Warrior", { weapon = t.string() }). |
colyseus.t |
Mirrors JS t.*. Sugar: t.number(0) ≡ t.number():default(0). Chain modifiers on the builder: t.uint8(100):noSync(). |
colyseus.room { config } |
Room definition. Config: max_clients, patch_rate, auto_dispose, seat_reservation_timeout, timestep (ms → on_timestep), fixed_tick_rate (Hz → on_fixed_timestep). |
| Hooks | on_create, on_join, on_leave, on_drop, on_reconnect, on_auth, on_dispose, on_before_patch, on_uncaught_exception, on_timestep(dt), on_fixed_timestep(dt, ctx). |
MyRoom.messages |
Declarative { [type] = function(self, client, message) end }; "*" wildcard gets (self, client, type, message). |
colyseus.on_message(self, type, fn) |
Dynamic registration (e.g. inside on_create). Returns unbind. |
colyseus.define_input(self, InputSchema, opts?) |
Exposes the protected defineInput. Drain per tick: for inp in colyseus.each(self.inputs:get(id)) do. |
colyseus.pairs(map) / ipairs(arr) / each(iter) / len(c) |
Iteration over JS collections (MapSchema yields key, value; ipairs is 1-based). |
colyseus.to_js(tbl) |
Deep Lua-table → JS Object/Array (needed for broadcast/send payloads; incoming messages need nothing). |
colyseus.locals(self) |
Per-room-instance Lua table (cleared on dispose). Room scripts must NOT keep mutable module-level locals — the chunk is shared across instances. |
colyseus.await(promise) |
Await any JS promise/thenable from a hook, message handler, or timer — suspends only that handler's coroutine. Rejections re-raise as Lua errors (pcall-able). Not allowed in tick hooks. |
colyseus.sleep(ms) |
Suspend the current handler. |
colyseus.set_timeout(self, fn, ms) / set_interval(self, fn, ms) |
Room-clock timers with safely trampolined callbacks (fn(self); errors route to on_uncaught_exception). Never pass a Lua function straight to self.clock:setTimeout — interop shifts its arguments and bypasses error handling. Returns timer (timer:clear()). |
colyseus.validate(spec, handler) |
Declarative message validation: { x = "number", tag = "string?", meta = { seq = "integer" } } — mismatches skip the handler and route to on_uncaught_exception. |
colyseus.view() |
Per-client StateView for .view()-tagged fields: client.view = colyseus.view(); client.view:add(entity) in on_join. |
| Lag compensation | The platformer pattern works from Lua: self.rewind = self:allowRewindState(colyseus.to_js({ maxRewindMs = 500 })), rewind:attachAll(map, colyseus.to_js({ fields = {"x","y"}, mode = "reckon" })), then per consumed input self.rewind:lastSeenBy(id):read(entity, fields, scratch). Requires colyseus.define_input. See test/fixtures/m7_rewind_room.lua. |
Any hook or message handler can suspend on a promise — on_auth with a real
credential lookup just works, and core awaits on_join before confirming:
function MyRoom:on_auth(client, options)
local user = colyseus.await(db:findUser(options.token))
return user ~= js.null
endError policy: lifecycle-hook errors keep Colyseus semantics (a failed on_auth
rejects the join). Message-handler, tick, and timer errors — sync or async —
route to on_uncaught_exception (or are logged) and never kill the process.
types/colyseus.lua ships LuaLS/EmmyLua annotations for the whole API, wired
via .luarc.json — autocomplete, hover docs, and diagnostics in any editor
running lua-language-server.
npm start in a terminal drops into a Lua prompt against the running VM
(disable with NO_REPL=1). Expression input is auto-returned, and
colyseus.await/colyseus.sleep work at the prompt:
lua> rooms()
[ { roomId: 'AbC123', name: 'my_room', clients: 2, ... } ]
lua> room("AbC123").state.players.size
2
lua> for id, p in colyseus.pairs(room("AbC123").state.players) do print(id, p.x) end
src/vm.ts one shared lua_State; script loading (per-chunk _ENV),
vm.call() trampoline (raw lua_pcall + luaL_traceback,
errors → JS Error with Lua traceback)
src/lua-room.ts makeLuaRoomClass(vm, path) → distinct Room subclass;
hooks resolve through the script handle AT CALL TIME
(that's what makes hot reload work on live rooms)
src/runtime/colyseus.lua the entire Lua-facing API above
src/index.ts createLuaVM / defineLuaRoom / watchLuaRooms
Two interop rules the whole design rests on (verified in test/m1):
- Never hand a raw Lua function to Colyseus. fengari-interop prepends the
JS
thisto a wrapped Lua function's arguments, so an unbound callback invocation shifts every parameter. All callbacks go throughvm.call(fn, room, ...)— which also means every handler receives the room asself, enablingfunction MyRoom:on_join(client)syntax. - Calling a bare JS function from Lua passes the first argument as
this. Use colon-calls on objects; the bridge's own helpers handle the rest.
npm run bench — per-tick cost of the Lua on_timestep over a MapSchema
(reads + writes through interop on every entity), Apple Silicon, Node 22:
| players | µs/tick | % of a 60 Hz frame |
|---|---|---|
| 50 | ~515 | 3.1% |
| 200 | ~2100 | 12.7% |
Scaling is linear at ~10.5 µs per player per tick. Plenty for room logic; keep heavy math (physics broadphase, pathfinding) in JS.
- Hot reload swaps hook and message-handler bodies on live rooms. Adding message types, changing room config, or changing schema shapes requires a restart (schema classes are cached per script by name; a changed field set logs a warning). Broken edits keep the previous version live.
- Lua 5.3, not 5.1/LuaJIT:
table.unpack,_ENV(nosetfenv),//, bitwise operators,math.type. Mostly forward-compatible for 5.1-style code. - Numbers: integers beyond 2^53 lose precision crossing to JS — avoid 64-bit integer IDs in Lua.
- Debugging is print + traceback (chunknames point at real files; every
error carries a Lua stack trace into
on_uncaught_exception/ logs). No step debugger. - Sandboxing:
luaL_openlibsexposesos/io/load, andjs.globalreaches everything. Fine for trusted first-party scripts; embedding untrusted scripts needs a whitelisted_ENV(the per-chunk_ENVseam exists) and nojsaccess — out of scope here. - Version coupling: the bridge reaches into
@colyseus/core/schemainternals (protecteddefineInput, builder shapes) — keep the peer ranges honest when core minors land. - No Lua
__gc/weak tables (Fengari): don't design around finalizers;localscleanup is explicit on dispose. Soak-tested: 20 create/dispose cycles under message load leak zero locals tables and zero coroutine anchors (vm.stats(),test/m8_hardening.test.ts).
@colyseus/testing's client-sidewaitForNextPatch()/waitForNextMessage()hookClientRoom.prototype.patch/dispatchMessage, which no longer exist on the 0.18 SDK Room (patching moved into the private frame dispatcher) — the promises never resolve. Server-side helpers and clientwaitForMessageare fine. Tests here poll instead.