Skip to content

Weapons ​

In Graal terms, a weapon is any script a player carries, not just a sword or a gun. HUDs, inventories, the player list, chat commands and movement are all weapons. It's the unit of clientside code: the only way to run your code on a player's client (apart from level NPCs) is to grant them a weapon.

Anatomy ​

A weapon named gun is up to two files in scripts/weapons/:

FileRunsInstances
gun.client.ts (required)In the client of every player who holds gunOne per holding client
gun.ts (optional)On the serverOne, shared by all holders

Both halves see this.name === 'gun'. The client half does anything local (input, GUI, images, animations) and asks the server half to do anything authoritative (money, items, warps, other players). See Project layout for how the files are compiled.

ts
// weapons/greeter.client.ts
function onCreated() {
    echo(`${this.name} loaded for ${player.account}`)
}

function onPlayerChats(who: ChatPlayer, chat: string) {
    if (who.id === player.id && chat === '/hello')
        triggerServer('weapon', this.name, 'hello')
}

function onActionClientSide(action: string, text: string) {
    if (action === 'reply') player.chat = text
}
ts
// weapons/greeter.ts
function onActionServerSide(player: Player, action: string) {
    if (action === 'hello')
        triggerClient('weapon', this.name, 'reply', `Hi ${player.nick}!`)
}

Lifecycle ​

Clientside half ​

EventWhen
onCreated()The weapon's script loaded on this client: shortly after login (from the download or the local cache), right after a mid-session grant, and again every time a new version is pushed by a hot reload
onUpdate(delta)Every frame. delta is seconds since the last frame
onKeyPressed(key)A key went down (once per press)
onPlayerChats(player, chat)Anyone in the level (you included) changed their chat, or you typed a /command
onActionClientSide(...params)The serverside half (or any server script) called triggerClient('weapon', <this weapon>, ...)
onShot(data), onShotAt(x, y, data)Projectile hits
onPMReceived(sender)Someone sent this player a PM. See Chat & PMs

There is no unload event. When the weapon is removed or replaced by a new version, the engine cleans up after it: its timers are cancelled, and its GUI controls and findimg images are destroyed. Build UI in onCreated and it will be rebuilt correctly after every reload.

Some client effects outlive the weapon

replaceAni replacements last until clearAnis() or the end of the session, even after the weapon that set them is removed. Undo them yourself, for example when an item is unequipped. The opposite also happens: when any weapon is removed, the client re-enables default movement, resets setfocus and restores full ambient light. A weapon that relies on disabledefmovement() should re-apply it in onCreated.

Serverside half ​

The server half is a normal server script (see Events & triggers). It loads when the server boots, whether or not anyone holds the weapon, and receives:

EventWhen
onCreated()Loaded at boot, and again on every hot reload
onInitialized()Once, at boot
onPlayerJoined(player), onPlayerLeft(player)Any player joins or leaves, holder or not
onActionServerSide(player, ...params)A holder's client called triggerServer('weapon', <this weapon>, ...)

Because one instance serves every holder, don't keep per-player state in module variables keyed implicitly by "the" player. Key it by player.account or player.id, or store it in the player's flags.

Granting and removing ​

A player only runs a weapon they've been granted. Grants come from:

  • Join-time grants in login.ts. The template grants serveroptions.json's startWeapons:

    ts
    function onPlayerJoined(pl: Player) {
        for (const wep of serverOptions.startWeapons ?? [])
            pl.addWeapon(wep)
    }
  • Scripts, any time: player.addWeapon(name) grants it (the client downloads and starts it immediately). player.removeWeapon(name) takes it away (the client unloads it immediately). player.hasWeapon(name) checks. addWeapon returns false only when no such weapon exists, and granting one the player already has is a harmless no-op.

  • Staff in GRC: Grant weapon… / Revoke weapon… in the Players window (grantweapons right).

Grants are persisted in the player's account file and restored at the next login. Weapons that no longer exist on the server are skipped silently. The item system grants a weapon when an item that uses it is acquired, and revokes it when the last one is gone. That's a good pattern for "equipment" weapons.

On the client, player.hasWeapon(name) turns true as soon as the grant arrives, even while the script is still downloading. Clients can't grant themselves weapons.

Talking to your own server half ​

triggerServer('weapon', this.name, ...) from the client half reaches onActionServerSide(player, ...) on the server half. triggerClient('weapon', this.name, ...) answers the same player. The server rejects 'weapon' triggers for weapons the player doesn't hold, so a hacked client can't call into weapons it was never given. Parameters are JSON round-tripped. The full rules, including rate limits, are in Events & triggers.

Treat everything arriving in onActionServerSide as untrusted input: validate types and ranges, and never let the client dictate prices, damage or quantities.

Weapon-to-weapon calls: findweapon ​

Sometimes one weapon needs to call into another directly, without a network trip. For example, the inventory weapon tells whichever gun is equipped that it was fired. findweapon (alias findWeapon) returns a WeaponHandle whose trigger(event, ...params) calls that script's exported handler synchronously:

ts
// Clientside: deliver a custom event to another loaded weapon.
findweapon('gun')?.trigger('onWeaponFired', entry)

// Serverside: same idea against weapons' serverside halves.
findweapon('potion')?.trigger('onItemUsed', player, entry, ctx)

The two sides differ slightly:

Server (findweapon)Client (findweapon)
FindsThe loaded serverside half weapons/<name>.ts. Plain server scripts are unreachable (names containing / return null)A weapon this client has loaded
null whenNo such serverside half is loadedNot granted, or the script hasn't arrived yet. Re-look it up per call, don't cache the handle
ParamsLive JS values. A Player passes through intact, and the callee can mutate objects you pass (e.g. setting ctx.consume = false on a passed ctx)Live JS values
ContextThe callee inherits the caller's "current player", so it can triggerClient the same playerGUI controls and images the callee creates belong to the callee

Semantics shared by both sides: the target's own export runs first, then any joined classes' exports, with this bound to the target. trigger returns false if the target has unloaded or its handler threw (the error is logged), and true otherwise, even if the target doesn't export that handler. Event names are free-form, so you can invent your own (onEquipped, onWeaponFired, ...).

Persistence: where weapon state should live ​

A weapon's module variables and this state last only as long as that loaded copy. They're lost on relog (client half), on server restart (server half) and on every hot reload of either. Put anything that must last somewhere durable:

DataStore it in
Per-player, client may write (settings, UI layout)client.* / player.client.* flags
Per-player, server-authoritative (money, equipped items)player.clientr.* flags, or an account-scoped collection
Globalserver.* / serverr.* flags, collections or SQLite