Appearance
Events & triggers
Scripts don't have a main. They export handlers named after events (onCreated, onPlayerJoined, onActionServerSide, ...), and the engine calls every loaded script that exports one. This page lists every event per script type, then covers the trigger protocol that connects clientside and serverside code.
How handlers are found
- A handler is a top-level
functionwhose name matches the event.exportis optional, because the build exports every top-level function (see Project layout). - Events are broadcast. Every loaded script of the right kind that exports the handler gets it, in load order. A script that doesn't export it is skipped silently.
- Handlers run with
thisbound to the script: a ScriptThis on the server, a WeaponThis in clientside weapons, the NPC in NPC scripts. Usefunctiondeclarations, because arrow functions don't see it (details). - If the script has joined classes, their exported handlers fire after the script's own.
- A handler that throws is logged (
Script 'x' failed handling onFoo: ...) and doesn't stop the event reaching other scripts.
Annotate this if you want it typed:
ts
export function onActionServerSide(this: ScriptThis, player: Player, action: string) {
echo(`${this.name}: ${action} from ${player.account}`)
}Server scripts
These fire on plain server scripts and weapons' serverside halves (both are server scripts):
| Handler | Fires | Current player? |
|---|---|---|
onCreated() | Every time the script loads: at boot, and again after every hot reload. Put (re)initialization and this.join(...) calls here | – |
onInitialized() | Once at boot, after onCreated. Not re-fired on reload. serverOptions is available from here on | – |
onPlayerJoined(player) | A player logged in to this server | ✔ |
onPlayerLeft(player) | A logged-in player disconnected. Flag writes made here are saved with the account | ✔ |
onActionServerSide(player, ...params) | A client called triggerServer targeting this script | ✔ |
"Current player ✔" means triggerClient and the global level work synchronously inside the handler (see the current player).
Server scripts have no onUpdate and don't hear chat. For periodic work, use setInterval. For chat, see Chat & PMs. Beyond these, you can define your own event names and call them through findweapon.
Clientside weapons
| Handler | Fires |
|---|---|
onCreated() | The weapon loaded on this client (after login, after a grant, after each pushed update) |
onUpdate(delta) | Every frame. delta is seconds since the previous frame |
onKeyPressed(key) | A key went down, once per press (held keys don't repeat). Silent while typing in the chat bar or a text field. See Camera, input & movement |
onActionClientSide(...params) | A server script called triggerClient('weapon', <this weapon>, ...params) |
onPlayerChats(player, chat) | Anyone in the level changed their chat (yourself included), or you typed a /command. See Chat & PMs |
onShotAt(x, y, data) | A projectile stopped in this level (wall or lifetime). See Projectiles |
onShot(data) | A projectile hit your player |
onPMReceived(sender) | Someone PM'd you, or a server script sent you a system PM |
GUI controls have their own events (onClick, onTextChanged, ...). See GUI controls. Login Server weapons have a different set: see Login server scripting.
NPC scripts
Level NPCs export many of the same names (onCreated, onUpdate, onPlayerChats, ...), but they're dispatched separately and never cross-fire with weapons. Serverside NPCs add onPlayerEnters(player), onAction<Name>(...) for triggerAction hits and onMovementFinished(). Clientside NPCs add onPlayerEnters(player) and onAction<Name>(...). The full list is in NPCs and the serverside / clientside NPC references.
The trigger round trip
Clientside and serverside code talk through two batched calls:
client server
triggerServer('weapon', 'shop', 'buy', 'sword') ─▶ onActionServerSide(player, 'buy', 'sword')
onActionClientSide('bought', 'sword', 120) ◀─ triggerClient('weapon', 'shop', 'bought', 'sword', 120)triggerServer (client → server)
triggerServer(scriptType, scriptName, ...params):
scriptType | Reaches | Rule |
|---|---|---|
'weapon' | onActionServerSide on scripts/weapons/<scriptName>.ts | The player must hold the weapon, otherwise the call is dropped and logged |
'script' | onActionServerSide on scripts/<scriptName>.ts | Names under weapons/ are refused, so weapons can't be reached around the grant check |
The server injects the triggering Player as the first argument. The client can't forge it. Everything after it is the client's params. scriptName never has an extension. Pass this.name to reach your own weapon's server half.
triggerClient (server → client)
triggerClient('weapon', scriptName, ...params) calls onActionClientSide(...params) on that weapon in the current player's client. There's no player argument. It always targets the player whose event is being handled, which makes it the natural reply to a triggerServer.
- It works only synchronously inside a player-scoped handler (
onActionServerSide,onPlayerJoined,onPlayerLeft, player-carrying NPC events, and handlers reached from those viafindweapon). After anawaitor in a timer it's dropped with a console line. 'weapon'is the only script type, because clients run nothing else.- If that player's client doesn't have the weapon loaded, the client logs it and drops the call.
To reach a player outside a player context (a timer, after a database query, another player), use state the client can see instead: a player.clientr flag, a replicated collection, or a system PM with sendPM.
Parameter serialization
Trigger params cross the network as a JSON array, so they obey JSON.stringify rules. They're serialized when you make the call, and the receiver gets fresh values:
| You pass | The receiver gets |
|---|---|
strings, finite numbers, booleans, null | the same |
| plain objects and arrays (nested) | deep copies |
undefined, functions, NaN, Infinity (as array elements) | null |
Date | an ISO string |
Map, Set, class instances | plain objects (a Map/Set becomes {}) |
cyclic objects, BigInt | throws at the call site |
Don't pass a server Player to triggerClient. Pass the fields you need (player.id, player.nick, ...). Values arrive untyped whatever your TypeScript signature says, so validate on the receiving side, especially on the server:
ts
function onActionServerSide(player: Player, action: string, ...params: any[]) {
if (action !== 'buy') return
const itemId = String(params[0] ?? '')
const qty = Math.floor(Number(params[1]))
if (!itemId || !(qty >= 1 && qty <= 99)) return // reject junk from modified clients
// ...
}Direct script-to-script calls through findweapon(...).trigger(...) skip serialization: they pass live values. See Weapons.
Rate limits
Each client gets a budget of 400 script-driven messages per second, shared by triggerServer, NPC triggerAction, client.* flag writes, and collection fetch/query requests. Anything over the budget is dropped and logged. Honest use, even drag-painting in the tile editor, stays far below it. If you're near it, send one trigger carrying an array instead of many small ones. triggerClient has no per-call cap, but every call is a packet, so don't send one per tick per player.
ScriptThis and WeaponThis
ScriptThis (server) and WeaponThis (client) type the this your handlers receive:
| Member | Meaning |
|---|---|
name | The script's bare name: 'shop' for shop.ts, weapons/shop.ts and weapons/shop.client.ts alike. Use it in triggers so renames don't break them |
join(name) / leave(name) / joinedclasses | Classes |
| anything else | Your own state, kept between calls (lost on hot reload) |
The script's own top-level functions and joined class exports are also callable as this.fn(...).