Skip to content

Repository files navigation

@colyseus/lua — Colyseus 0.18 rooms written in Lua (Fengari)

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 terminal

Running

Requires 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

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.

Lua API

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.

Async hooks

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
end

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

Editor support

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.

Live REPL

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

Architecture

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):

  1. Never hand a raw Lua function to Colyseus. fengari-interop prepends the JS this to a wrapped Lua function's arguments, so an unbound callback invocation shifts every parameter. All callbacks go through vm.call(fn, room, ...) — which also means every handler receives the room as self, enabling function MyRoom:on_join(client) syntax.
  2. 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.

Performance

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.

Limitations & caveats

  • 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 (no setfenv), //, 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_openlibs exposes os/io/load, and js.global reaches everything. Fine for trusted first-party scripts; embedding untrusted scripts needs a whitelisted _ENV (the per-chunk _ENV seam exists) and no js access — out of scope here.
  • Version coupling: the bridge reaches into @colyseus/core/schema internals (protected defineInput, builder shapes) — keep the peer ranges honest when core minors land.
  • No Lua __gc/weak tables (Fengari): don't design around finalizers; locals cleanup 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).

Upstream findings

  • @colyseus/testing's client-side waitForNextPatch()/waitForNextMessage() hook ClientRoom.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 client waitForMessage are fine. Tests here poll instead.

About

Demo using LUA (fengari) for writing backend game servers with Colyseus

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages