Skip to content

Classes ​

A class is a script that other scripts join to borrow its behaviour — Graal's join("classname"). It is the main way to share code between NPCs, whose scripts are otherwise copied into every level file, and it works just as well for weapons and plain server scripts.

A class is not a JavaScript class. It is a module of handlers and helpers that runs as the script that joined it: inside a class function, this is the joining NPC or weapon.

When to use a class (vs a lib module) ​

You want to…Use
Give many NPCs the same event handlers (onCreated, onActionTalk, onUpdate, …)a class
Change that behaviour for every NPC at once, live, without re-saving levelsa class
Share pure helpers, constants and types between scriptsa lib module
Share a type between the server and client halvesa lib module

Classes are joined at runtime and hot-reload onto everything that joined them. Lib modules are inlined into each script at build time.

Class files ​

Classes live in scripts/classes/, and are edited in GRC's Classes window. A class can have a serverside half, a clientside half, or both:

scripts/classes/
  shop_item.ts          ← serverside half
  shop_item.client.ts   ← clientside half

The class name is the file name without the extension; names may contain letters, digits, _ and - (up to 64 characters). Serverside halves see the server globals, clientside halves the client globals and GUI API. Neither half gets npc.*.d.ts typings — this is untyped (any) in class code, because a class can be joined by an NPC, a weapon or a plain script.

As in every script, export is optional: each top-level function is part of the class.

Joining ​

Call this.join(name) from any script — serverside or clientside NPCs, weapons, plain server scripts (ScriptThis, WeaponThis):

ts
// Serverside half of a level NPC — just configuration + join.
export function onCreated() {
    this.npc = { kind: 'say', arg: ['Hi!'], head: 'head3.png', body: 'body2.png', wander: 1 }
    this.join('talker')
}
ts
// classes/talker.ts (example: NPCs that hand talk events to a game weapon)
export function onCreated() {
    const c = this.npc
    if (c.head) {
        this.showCharacter()
        this.head = c.head
        this.body = c.body ?? ''
    }
    this.setShape(0, 0, 32, 32)
}

export function onActionTalk(player: Player) {
    findweapon('dialogue')?.trigger('onNpcTalk', player, this.npc.kind, this.npc.arg ?? null)
}

What join does:

  1. Binds the class to the script. From now on the class's handlers fire for this script's events, with the script as this.
  2. Runs the class's onCreated immediately, with the joiner as this — so configuration set before the join() call is visible to it.
  3. On the server, also joins the class's clientside half on every client: an NPC's clientside script everywhere in the level, or a weapon's clientside script for every player who has the weapon (including players who log in later).

It returns false when the class doesn't exist on that side — a serverside join needs a serverside half; a clientside join needs a .client.ts. this.leave(name) unbinds it, and this.joinedclasses lists the joined names in join order.

Clientside joins can be deferred

The client downloads class scripts on demand (and caches them). A clientside join of a class it doesn't have yet returns true right away; the class's onCreated runs once the script arrives.

Dispatch: both handlers fire ​

When an event fires on a script that has joined classes, every handler runs: the script's own handler first, then each class's in join order. A class handler never replaces the script's own:

ts
// NPC script
export function onActionTalk(player: Player) {
    echo('npc handler')      // runs first
}
export function onCreated() {
    this.join('greeter')     // greeter's onActionTalk runs second
}

A class joined during an event does not receive that same event — joining inside onCreated fires the class's onCreated once (from the join), not twice.

Helpers and this ​

Exported class functions that aren't event handlers become methods on the joiner: call them as this.helper(). The script's own top-level functions are callable the same way. When names collide, the most recently joined class wins, then earlier classes, then the script's own function (event dispatch still fires all of them). Class members type as any, since the typings can't know what you've joined.

ts
// classes/talker.ts
export function onCreated() {
    if (this.wanderTimer) clearInterval(this.wanderTimer)
    this.wanderTimer = setInterval(() => this.wander(), 2500)
}

export function wander() {
    // `this` is the NPC that joined
    this.move(2, 0, 0.35, 8)
}

Inside a top-level function, this is always the running script, even for a bare helper() call. Arrow functions inside a handler keep this too; nested function expressions don't.

Class state ​

There are two kinds of state and it matters which one you use:

  • this.foo — per joiner. Each NPC that joined talker has its own this.npc, this.homeX, … This is where per-instance state belongs.
  • Module-level variables (let count = 0 at the top of the class file) — shared by every joiner, like a static. Use it for caches and counters that are genuinely global to the class, and remember it is reset when the class is hot-reloaded.
ts
// classes/counter.ts
let spawned = 0                    // shared across all joiners

export function onCreated() {
    spawned++
    this.serial = spawned          // per joiner
}

Hot reload ​

Saving a class in GRC recompiles it and rebinds it onto every live script that joined it — all NPCs across all levels, weapons, scripts — then re-fires the class onCreated on each joiner. Clientside halves are re-sent to clients and rebound the same way. No level has to be re-saved.

onCreated runs again — make it idempotent

Because a class reload re-fires onCreated on joiners that are already running, anything it starts must be safe to start twice. Clear old timers before setting new ones (as talker does with this.wanderTimer), and don't blindly append to per-joiner arrays. The same applies to re-joining: calling join() again for an already-joined class re-fires its onCreated.

Where to call join() ​

Put join() calls in onCreated.

  • NPCs are re-created by a level hot reload, which re-runs onCreated and re-joins.
  • Weapons and plain server scripts lose their joins when their own script is reloaded. onCreated fires on every load (boot and each hot reload), so joins made there come back automatically. onInitialized fires only once at server startup — a join made there is gone after the first script update.
ts
// scripts/report.ts — a plain server script
export function onCreated() {
    this.join('reporting')     // re-made on every reload
}

Clientside halves on their own ​

A weapon's or NPC's clientside script can join a class's clientside half directly. Clientside joins are local to that client — the server doesn't know about them. GUI controls and images a class's onCreated creates during the join belong to the joiner and are cleaned up when the joiner unloads.

See also ​