Appearance
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 levels | a class |
| Share pure helpers, constants and types between scripts | a lib module |
| Share a type between the server and client halves | a 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 halfThe 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:
- Binds the class to the script. From now on the class's handlers fire for this script's events, with the script as
this. - Runs the class's
onCreatedimmediately, with the joiner asthis— so configuration set before thejoin()call is visible to it. - 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 joinedtalkerhas its ownthis.npc,this.homeX, … This is where per-instance state belongs.- Module-level variables (
let count = 0at 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
onCreatedand re-joins. - Weapons and plain server scripts lose their joins when their own script is reloaded.
onCreatedfires on every load (boot and each hot reload), so joins made there come back automatically.onInitializedfires 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
- NPCs — the most common joiners
- Shared lib modules — build-time code sharing
- Tutorial 5: NPCs with a shared class