Skip to content

Execution model ​

Scripts are TypeScript compiled to ES modules and run in ClearScript V8 engines embedded in the server and the client. Knowing when your code runs, which calls take effect immediately and which are deferred, and what this is, will save you most of the confusing bugs. This page covers all of that, plus timers, the "current player", async handlers and hot reload.

Engines and threads ​

SideEngineRunsDriven by
ServerOne V8 enginePlain server scripts, weapons' serverside halves, classes, serverside NPC scriptsThe server tick loop, 20 ticks per second (50 ms)
ClientOne V8 engine per game-server connectionWeapons' clientside halves, clientside NPC scripts, gani scriptsThe game loop, once per frame
ClientThe system runtime, a separate engineLogin Server weapons onlyOnce per frame. See Login server scripting

Every script on one side shares a single thread. On the server, each tick handles network packets, advances projectiles and NPCs, fires due timers, delivers onUpdate to NPCs, then flushes collection deltas. Everything runs in turn, and nothing runs in parallel with your handler.

A slow handler stalls the whole server

There is no preemption. A long synchronous loop in any handler delays every other script, every player's movement and every packet until it returns. Break long work up with await sleep(...), or run heavy queries through SQLite, which executes on a worker thread.

Batched calls vs direct calls ​

Crossing between JavaScript and the C# host is expensive, so the engine splits host functions into two kinds.

Batched (fire-and-forget) calls are queued in JavaScript and delivered to the host together at the next flush point. They can't return values, and their arguments must be JSON-serializable. They're serialized when you make the call, so a non-serializable argument (a cyclic object, a BigInt) throws right there. Order between batched calls is preserved.

Direct calls reach the host immediately and can return values. Anything that returns something or takes a callback is direct.

ServerClient
Batchedecho, triggerClient, sendPMecho, triggerServer, setAni, setfocus, disabledefmovement, updatelevel, writes to player.x/y/dir/chat/head/body/colors, image and GUI property writes
Directsleep, timers, flag reads/writes, createlevel, setleveltile, Player reads and writes, findweapon, collections, opendatabasesleep, timers, keydown, mousex, gettile, onwall, measuretext, replaceAni, clearAnis, client/clientr/serverr reads, findweapon

Flush points ​

The queue is flushed whenever control returns to the host: after each event handler dispatch, after a script's module loads, and after each tick's (or frame's) timer callbacks. In practice a batched call takes effect when the current handler returns, and on the server no later than the end of the current 50 ms tick.

That matters in two places:

  • Read-your-writes on the client. Writes to player.x, player.chat, image properties and so on are readable immediately (player.x += 2 twice moves by 4), but they only apply at the flush. For example, clamping to the level bounds happens then. Any number of writes in one flush window fold into one host command.
  • Ordering with direct calls. Direct calls run immediately, and batched ones run later. An echo followed by a direct call prints after the direct call has already happened.

this binding ​

Every handler runs with this bound to the script's own object:

  • In server scripts and weapon server halves: a ScriptThis (this.name, this.join(), ...).
  • In clientside weapons: a WeaponThis.
  • In NPC scripts: the NPC itself (this.x, this.level, ...). See NPCs.

The object is yours to keep state on between calls. For example, a weapon can do this.test = 0 in onCreated. Because the build rewrites this inside top-level functions, the rules are:

ts
function onCreated() {
    this.count = 0                    // ✔ handler: `this` is the script
    bump()                            // ✔ bare helper call: `this` is still the script
    setTimeout(() => this.count++, 1000) // ✔ arrow inside a function: lexical `this`
}

function bump() {
    this.count++                      // ✔ top-level function: falls back to the script
}

export const onPlayerJoined = (pl: Player) => {
    this.count++                      // ✘ top-level arrow: never sees the script
}

function onUpdate() {
    [1, 2].forEach(function () {
        this.count++                  // ✘ nested `function`: normal JS rules, undefined
    })
}

The rule: use function declarations for handlers and helpers, and arrows only inside them. The script's own functions (and those of joined classes) are also callable as this.helper().

Timers and sleep ​

setTimeout, setInterval, clearTimeout, clearInterval and sleep exist on both sides with browser-like signatures, but they're driven by the tick (server) or the frame (client):

  • Resolution is rounded up. On the server, setTimeout(fn, 10) fires at the next tick (up to 50 ms later). setTimeout(fn, 0) never runs synchronously. It runs on the next pass of the scheduler.
  • Intervals fire at most once per tick or frame. A lagging server does not "catch up" missed runs. setInterval(fn, 10) on the server effectively runs every 50 ms.
  • sleep(seconds) takes seconds, not milliseconds, and only makes sense in an async function. Other scripts keep running while one sleeps.
  • Timers belong to the script that created them. Callbacks run with the same this as the script's handlers (for NPCs, the NPC). When the script unloads or hot-reloads, its timers are cancelled and pending sleeps never resume.
  • A throwing callback is logged and isolated. A throwing interval stays armed.
ts
// Server: a countdown that doesn't block anything else.
async function onInitialized() {
    for (let i = 5; i > 0; i--) {
        echo(`restart in ${i}...`)
        await sleep(1)
    }
}

The "current player" ​

Some server calls act on "the player this code is running for": triggerClient and the global level object. That context exists only synchronously inside a player-scoped event:

  • onActionServerSide(player, ...)
  • onPlayerJoined(player) and onPlayerLeft(player)
  • NPC events that carry a player (onPlayerEnters, onPlayerChats, onAction<Name> from a client)
  • handlers reached through findweapon(...).trigger(...) from one of the above (the context is inherited)

After an await, and inside any timer callback, the context is gone. triggerClient is then dropped with the console line [script] triggerClient called outside a player context; dropped., and level.name reads ''.

ts
async function onActionServerSide(player: Player, action: string) {
    triggerClient('weapon', this.name, 'working')   // ✔ still synchronous
    const rows = await opendatabase('shop').query('SELECT * FROM items')
    triggerClient('weapon', this.name, 'done', rows) // ✘ dropped: no current player after await
}

Workarounds: finish all triggerClient calls before the first await. Answer through something that doesn't need a context, such as a flag (player.clientr.x = ... works on a kept Player), a replicated collection or sendPM. Or keep the data in memory so the whole handler stays synchronous. A shop that loads its catalog into memory at startup can answer purchases without any await.

Player objects are snapshots

A Player handed to an event is a snapshot taken when the event fired. player.x, player.y and player.level don't update afterwards. chat, ani, head, body, nick, colors and the flag bags are live. A Player kept across an await may describe someone who has since moved on or logged off. See Players.

Async handlers ​

Any handler may be async. The engine calls it and does not wait for the returned promise: the next event can reach the script while an earlier one is still suspended in an await. Guard shared state accordingly. For example, set a busy flag on this before awaiting.

Errors thrown synchronously in a handler are caught per script and logged as Script '<name>' failed handling <event>: <message>, so one broken handler doesn't stop the event reaching other scripts. The engine doesn't log errors that surface after an await (a rejected promise nobody handles). Wrap the async part in try/catch and echo the error yourself.

Hot reload ​

Saving a script in GRC recompiles it and swaps it into the running server, and into clients for clientside code, without a restart. See Debugging & tooling for the workflow. What happens to the script depends on what state it was keeping:

Survives a reload?
Module-level variables (let cache = ...)No. The new version is a fresh module
State on thisNo. The script gets a fresh this object
Timers, intervals, pending sleepsNo. Cancelled, and suspended async loops never resume
Class joins (this.join(...))No. Re-join in onCreated
Client GUI controls and findimg images owned by the weaponNo. Swept on unload, rebuild in onCreated
server/serverr/client/clientr flags, collections, databasesYes. They live outside the script
Weapon grants, player positions, levelsYes

Lifecycle events on reload:

  • Server scripts and weapon server halves: onCreated fires again. onInitialized does not. It runs only once, at server startup.
  • Clientside weapons: the server pushes the new version to every holder. The client unloads the old copy and runs onCreated on the new one.
  • NPCs: a level update re-creates every NPC in it (onCreated, then onPlayerEnters again).

So put (re)initialization, including this.join(...) calls and GUI construction, in onCreated, and keep boot-only work (seeding data, one-time migrations) in onInitialized.

Only changed scripts reload

A save rebuilds only the saved file's entry (plus, for a lib/ file, every script that imports it), and only scripts whose compiled output actually changed are swapped. Other scripts keep their state.