Skip to content

1. Chat commands ​

In this tutorial you build a commands weapon that adds slash commands to the chat bar:

  • /help lists the commands.
  • /where prints your level and position, including the gmap member level you're standing in.
  • /roll [sides] rolls a die on the server and shows the result in a chat bubble that everyone in the level can see.
  • /nick <name> changes the name shown under your character, after the server checks it. /nick on its own resets it.
  • /warp <level> [x y] teleports you, but only if your staff rights allow it.

Every command answers with a line of text at the bottom of the screen that only you can see.

What you'll learn

  • How slash commands reach clientside scripts through onPlayerChats, and why they never reach the server unless you send them.
  • The standard request/response round trip: triggerServer → onActionServerSide → triggerClient → onActionClientSide.
  • Which commands can stay on the client, and which the server has to validate.
  • How to check staff rights from a script with player.hasright.

The finished weapon is two files, weapons/commands.client.ts and weapons/commands.ts, in docs/examples/chat-commands/. See Installing an example.

How slash commands travel ​

When a player submits chat that starts with /, the client treats it as a command. It isn't shown as a bubble and it isn't sent to the server. It goes to one place only: the onPlayerChats handler of every clientside script on that player's own client, with the command text as chat. (The built-in setnick, sethead, setbody and showname commands have no slash and are handled by the client itself.)

This split decides how each command is built:

  • A command that only reads local state, like /where, can be answered right away on the client.
  • A command that changes shared state or must be trustworthy, like /roll, /nick or /warp, has to be forwarded to the weapon's serverside half. The server checks the request and does the work. A modified client can send anything, so the server treats every parameter as untrusted.

See Events & triggers and Chat & private messages for the full event list.

Step 1: a reply line ​

Every command needs a way to answer. Instead of echo (which only writes to the client console), the weapon draws one line of text in screen space with findimg:

ts
const REPLY_IMG = 1          // findimg id of the reply line
const REPLY_SECONDS = 6      // how long a reply stays on screen

// Shows one line of feedback near the bottom of the screen. Replies are
// client-only images: nobody else sees them.
function say(text: string, ok: boolean = true) {
    const img = findimg(REPLY_IMG)
    img.screen = true
    img.text = text
    img.style = 'b'
    img.fontsize = 14
    img.textshadow = true
    img.tint = ok ? '255,255,255' : '255,120,120'
    img.x = 16
    img.y = ScreenHeight - 64
    img.alpha = 1
    this.replyLeft = REPLY_SECONDS
}

// findimg only works inside handlers (not inside timer callbacks), so the
// fade-out is driven from onUpdate instead of a setTimeout.
export function onUpdate(dt: number) {
    if (!(this.replyLeft > 0)) return
    this.replyLeft -= dt
    if (this.replyLeft <= 0)
        hideimg(REPLY_IMG)
    else if (this.replyLeft < 1)
        findimg(REPLY_IMG).alpha = this.replyLeft
}

A few things to notice:

  • img.screen = true switches the image to screen pixels (ScriptImage.screen). Setting a non-empty text draws text instead of an image.
  • say is a plain helper, and it can still use this. Inside any top-level function, this is the weapon, even when a handler calls the helper directly as say(...). this.replyLeft is ordinary weapon state that is kept between calls (WeaponThis).
  • The fade-out runs in onUpdate, not in a setTimeout. findimg only works inside handlers, so a timer callback couldn't touch the image. hideimg destroys the image when its time is up.

Step 2: parse commands on the client ​

onPlayerChats(who, chat) fires whenever any player in the level chats, so the handler first makes sure the chat came from the local player and is a command:

ts
export function onCreated() {
    this.replyLeft = 0
    echo('commands loaded - type /help in the chat bar')
}

export function onPlayerChats(who: ChatPlayer, chat: string) {
    // onPlayerChats fires for EVERY player in the level. Slash commands are
    // only ever delivered for the local player, but plain chat from others
    // arrives here too - ignore it.
    if (who.id !== player.id || !chat.startsWith('/'))
        return

    const args = chat.trim().split(/\s+/)
    const command = args.shift()!.toLowerCase()

    switch (command) {
        case '/help':
            say('/where  /roll [sides]  /nick <name>  /warp <level> [x y]')
            return
        case '/where':
            where()
            return
        case '/roll':
            triggerServer('weapon', this.name, 'roll', Number(args[0] ?? 6))
            return
        case '/nick':
            triggerServer('weapon', this.name, 'nick', args.join(' '))
            return
        case '/warp':
            if (args.length === 0) { say('Usage: /warp <level> [x y]', false); return }
            triggerServer('weapon', this.name, 'warp', args[0], Number(args[1] ?? 30), Number(args[2] ?? 30))
            return
    }
    // Other weapons may implement their own commands, so stay quiet about
    // commands this weapon doesn't know... except for a typo hint.
    if (command.length > 1 && '/help'.startsWith(command))
        say('Did you mean /help?', false)
}

who is a ChatPlayer snapshot. Comparing its id with player.id is the reliable way to recognise yourself.

The weapon stays quiet about commands it doesn't recognise. Several weapons can each handle their own commands, and they all receive every command, so an "Unknown command" reply from one weapon would be wrong whenever another weapon handles the command.

/where: answered locally ​

/where needs nothing from the server:

ts
// A purely local command: everything it needs is already on this client.
function where() {
    const x = player.x.toFixed(1)
    const y = player.y.toFixed(1)
    // On a gmap, player.x/y are gmap-global; gmaptolevel names the member
    // level underneath. On a plain level it returns null.
    const member = gmaptolevel('', player.x, player.y)
    if (member)
        say(`${player.level} (${x}, ${y}) - in ${member.level} at (${member.x.toFixed(1)}, ${member.y.toFixed(1)})`)
    else
        say(`${player.level} (${x}, ${y})`)
}

On a gmap, player.x/y are gmap-global. The clientside gmaptolevel accepts '' for "the current gmap" and returns the member level under a point, or null on a plain level. See Levels & gmaps.

Forwarding to the server ​

The other three commands call triggerServer:

ts
triggerServer('weapon', this.name, 'roll', Number(args[0] ?? 6))

'weapon' plus this.name targets this weapon's own serverside half, weapons/commands.ts. The remaining arguments arrive after the player in onActionServerSide(player, 'roll', 6). They must be JSON-serializable, and the server only accepts triggers for weapons the player actually has.

Step 3: the serverside half ​

Every request arrives in onActionServerSide, and the weapon dispatches on the action name:

ts
export function onActionServerSide(player: Player, action: string, ...params: any[]) {
    switch (action) {
        case 'roll': roll(player, params[0]); return
        case 'nick': nick(player, params[0]); return
        case 'warp': warp(player, params[0], params[1], params[2]); return
    }
}

Replies go back with triggerClient:

ts
// triggerClient answers the player whose event is being handled - valid
// here because every call below happens synchronously inside
// onActionServerSide (never after an await or in a timer).
function reply(text: string, ok: boolean = true) {
    triggerClient('weapon', this.name, 'reply', text, ok)
}

triggerClient always answers the current player, the one whose event is being handled. That player only exists synchronously inside a player-scoped handler such as onActionServerSide. If you await something first, or call triggerClient from a timer, the call is dropped (and logged in the server log). All of the handlers in this weapon are synchronous for that reason. See Execution model.

/roll: server-side randomness, broadcast as chat ​

ts
const ROLL_COOLDOWN_MS = 3000
// Module state lives until the script reloads - fine for a cooldown.
const lastRoll = new Map<number, number>()

function roll(player: Player, sidesParam: unknown) {
    const now = Date.now()
    if (now - (lastRoll.get(player.id) ?? 0) < ROLL_COOLDOWN_MS) {
        reply('Slow down - one roll every 3 seconds.', false)
        return
    }
    lastRoll.set(player.id, now)

    let sides = Math.floor(Number(sidesParam))
    if (!Number.isFinite(sides)) sides = 6
    sides = Math.min(1000, Math.max(2, sides))

    // The roll happens HERE, so a modified client can't pick its result.
    const result = 1 + Math.floor(Math.random() * sides)

    // Assigning player.chat shows a bubble to everyone in the player's level
    // - that's the "broadcast".
    player.chat = `*rolls a d${sides}: ${result}*`
    reply(`You rolled ${result} (1-${sides}).`)
}

export function onPlayerLeft(player: Player) {
    lastRoll.delete(player.id)
}
  • The random number is generated on the server. If the client rolled and just reported the result, a modified client could always roll 20.
  • Assigning player.chat on the server sets the player's chat bubble for everyone in their level. That is how the result is broadcast. The player also gets a private reply through triggerClient.
  • The cooldown lives in a module-level Map keyed by player.id. Module state survives between calls but is reset when the script hot-reloads. That's fine for a cooldown; anything that must last goes in flags. onPlayerLeft cleans up entries for players who log out.

/nick: validate, then assign ​

ts
const NICK_PATTERN = /^[A-Za-z0-9 _.-]{2,20}$/

function nick(player: Player, nameParam: unknown) {
    const name = String(nameParam ?? '').trim()
    if (name === '') {
        player.nick = ''   // '' resets to the account name
        reply(`Your name is ${player.account} again.`)
        return
    }
    if (!NICK_PATTERN.test(name)) {
        reply('Names are 2-20 letters, digits, spaces, _ . or -', false)
        return
    }
    player.nick = name
    reply(`You are now known as ${name}.`)
}

player.nick is the name drawn under the character. It is writable on the server, and assigning '' resets it to the account name. The engine sanitizes and trims nicknames itself, but your game may want stricter rules, such as the pattern above. The clientside player.nick is read-only, which is why this command goes through the server. See Players.

/warp: staff only ​

ts
function warp(player: Player, levelParam: unknown, xParam: unknown, yParam: unknown) {
    let levelName = String(levelParam ?? '').trim()
    // Accept "/warp town" as shorthand for town.glvl.
    if (levelName !== '' && !levelName.includes('.'))
        levelName += '.glvl'
    if (!/^[A-Za-z0-9_-]+\.(glvl|gmap)$/.test(levelName)) {
        reply('Usage: /warp <level> [x y]', false)
        return
    }

    // Staff-only: scripts check staff rights through folder rights. Here,
    // "may warp to a level" = "may edit that level's file". Staff accounts
    // without a rights record pass every check; regular players fail.
    if (!player.hasright('w', `levels/${levelName}`)) {
        reply('You are not allowed to warp there.', false)
        return
    }

    const x = Number(xParam)
    const y = Number(yParam)
    if (!Number.isFinite(x) || !Number.isFinite(y)) {
        reply('Coordinates must be numbers.', false)
        return
    }

    // warpto is server-only and returns false for unknown levels. The
    // client transitions once it has downloaded the destination.
    if (player.warpto(levelName, x, y))
        reply(`Warping to ${levelName} (${x}, ${y})...`)
    else
        reply(`There is no level called ${levelName}.`, false)
}

Scripts don't check named staff rights directly. They check folder rights with player.hasright(mode, path), where path is relative to data/. Here the rule is "you may warp to a level if you may edit its file". That is a natural fit, because level editors are exactly the people who need to jump around. How it resolves:

  • Accounts listed in staff in serveroptions.json that have no rights record pass every check.
  • Accounts with a rights record pass only if a folder rule such as rw levels/* matches.
  • Everyone else fails.

player.warpto is server-only. It returns false for a level that doesn't exist, and the client only switches levels once the destination has downloaded. See Staff rights & server options.

Step 4: show the replies ​

Back on the client, onActionClientSide receives what triggerClient sent:

ts
// The serverside half answers with triggerClient('weapon', 'commands', ...).
export function onActionClientSide(action: string, ...params: any[]) {
    if (action === 'reply')
        say(String(params[0] ?? ''), params[1] !== false)
}

The complete weapon ​

weapons/commands.client.ts
ts
// Clientside half of the `commands` weapon. Chat that starts with '/' never
// leaves this client: it arrives here, in onPlayerChats, and nowhere else.
// Commands that only read local state are answered right here; anything
// that needs authority (dice rolls, renaming, warping) is forwarded to the
// serverside half with triggerServer and answered with triggerClient.

const REPLY_IMG = 1          // findimg id of the reply line
const REPLY_SECONDS = 6      // how long a reply stays on screen

// Shows one line of feedback near the bottom of the screen. Replies are
// client-only images: nobody else sees them.
function say(text: string, ok: boolean = true) {
    const img = findimg(REPLY_IMG)
    img.screen = true
    img.text = text
    img.style = 'b'
    img.fontsize = 14
    img.textshadow = true
    img.tint = ok ? '255,255,255' : '255,120,120'
    img.x = 16
    img.y = ScreenHeight - 64
    img.alpha = 1
    this.replyLeft = REPLY_SECONDS
}

// findimg only works inside handlers (not inside timer callbacks), so the
// fade-out is driven from onUpdate instead of a setTimeout.
export function onUpdate(dt: number) {
    if (!(this.replyLeft > 0)) return
    this.replyLeft -= dt
    if (this.replyLeft <= 0)
        hideimg(REPLY_IMG)
    else if (this.replyLeft < 1)
        findimg(REPLY_IMG).alpha = this.replyLeft
}

export function onCreated() {
    this.replyLeft = 0
    echo('commands loaded - type /help in the chat bar')
}

export function onPlayerChats(who: ChatPlayer, chat: string) {
    // onPlayerChats fires for EVERY player in the level. Slash commands are
    // only ever delivered for the local player, but plain chat from others
    // arrives here too - ignore it.
    if (who.id !== player.id || !chat.startsWith('/'))
        return

    const args = chat.trim().split(/\s+/)
    const command = args.shift()!.toLowerCase()

    switch (command) {
        case '/help':
            say('/where  /roll [sides]  /nick <name>  /warp <level> [x y]')
            return
        case '/where':
            where()
            return
        case '/roll':
            triggerServer('weapon', this.name, 'roll', Number(args[0] ?? 6))
            return
        case '/nick':
            triggerServer('weapon', this.name, 'nick', args.join(' '))
            return
        case '/warp':
            if (args.length === 0) { say('Usage: /warp <level> [x y]', false); return }
            triggerServer('weapon', this.name, 'warp', args[0], Number(args[1] ?? 30), Number(args[2] ?? 30))
            return
    }
    // Other weapons may implement their own commands, so stay quiet about
    // commands this weapon doesn't know... except for a typo hint.
    if (command.length > 1 && '/help'.startsWith(command))
        say('Did you mean /help?', false)
}

// A purely local command: everything it needs is already on this client.
function where() {
    const x = player.x.toFixed(1)
    const y = player.y.toFixed(1)
    // On a gmap, player.x/y are gmap-global; gmaptolevel names the member
    // level underneath. On a plain level it returns null.
    const member = gmaptolevel('', player.x, player.y)
    if (member)
        say(`${player.level} (${x}, ${y}) - in ${member.level} at (${member.x.toFixed(1)}, ${member.y.toFixed(1)})`)
    else
        say(`${player.level} (${x}, ${y})`)
}

// The serverside half answers with triggerClient('weapon', 'commands', ...).
export function onActionClientSide(action: string, ...params: any[]) {
    if (action === 'reply')
        say(String(params[0] ?? ''), params[1] !== false)
}
weapons/commands.ts
ts
// Serverside half of the `commands` weapon. Every request arrives through
// onActionServerSide from the player's own client, so treat each parameter
// as untrusted input: re-validate types, ranges and permissions here.

// triggerClient answers the player whose event is being handled - valid
// here because every call below happens synchronously inside
// onActionServerSide (never after an await or in a timer).
function reply(text: string, ok: boolean = true) {
    triggerClient('weapon', this.name, 'reply', text, ok)
}

export function onActionServerSide(player: Player, action: string, ...params: any[]) {
    switch (action) {
        case 'roll': roll(player, params[0]); return
        case 'nick': nick(player, params[0]); return
        case 'warp': warp(player, params[0], params[1], params[2]); return
    }
}

const ROLL_COOLDOWN_MS = 3000
// Module state lives until the script reloads - fine for a cooldown.
const lastRoll = new Map<number, number>()

function roll(player: Player, sidesParam: unknown) {
    const now = Date.now()
    if (now - (lastRoll.get(player.id) ?? 0) < ROLL_COOLDOWN_MS) {
        reply('Slow down - one roll every 3 seconds.', false)
        return
    }
    lastRoll.set(player.id, now)

    let sides = Math.floor(Number(sidesParam))
    if (!Number.isFinite(sides)) sides = 6
    sides = Math.min(1000, Math.max(2, sides))

    // The roll happens HERE, so a modified client can't pick its result.
    const result = 1 + Math.floor(Math.random() * sides)

    // Assigning player.chat shows a bubble to everyone in the player's level
    // - that's the "broadcast".
    player.chat = `*rolls a d${sides}: ${result}*`
    reply(`You rolled ${result} (1-${sides}).`)
}

export function onPlayerLeft(player: Player) {
    lastRoll.delete(player.id)
}

const NICK_PATTERN = /^[A-Za-z0-9 _.-]{2,20}$/

function nick(player: Player, nameParam: unknown) {
    const name = String(nameParam ?? '').trim()
    if (name === '') {
        player.nick = ''   // '' resets to the account name
        reply(`Your name is ${player.account} again.`)
        return
    }
    if (!NICK_PATTERN.test(name)) {
        reply('Names are 2-20 letters, digits, spaces, _ . or -', false)
        return
    }
    player.nick = name
    reply(`You are now known as ${name}.`)
}

function warp(player: Player, levelParam: unknown, xParam: unknown, yParam: unknown) {
    let levelName = String(levelParam ?? '').trim()
    // Accept "/warp town" as shorthand for town.glvl.
    if (levelName !== '' && !levelName.includes('.'))
        levelName += '.glvl'
    if (!/^[A-Za-z0-9_-]+\.(glvl|gmap)$/.test(levelName)) {
        reply('Usage: /warp <level> [x y]', false)
        return
    }

    // Staff-only: scripts check staff rights through folder rights. Here,
    // "may warp to a level" = "may edit that level's file". Staff accounts
    // without a rights record pass every check; regular players fail.
    if (!player.hasright('w', `levels/${levelName}`)) {
        reply('You are not allowed to warp there.', false)
        return
    }

    const x = Number(xParam)
    const y = Number(yParam)
    if (!Number.isFinite(x) || !Number.isFinite(y)) {
        reply('Coordinates must be numbers.', false)
        return
    }

    // warpto is server-only and returns false for unknown levels. The
    // client transitions once it has downloaded the destination.
    if (player.warpto(levelName, x, y))
        reply(`Warping to ${levelName} (${x}, ${y})...`)
    else
        reply(`There is no level called ${levelName}.`, false)
}

Try it ​

  1. Create both halves of commands in GRC's Weapons editor and save, then grant it to yourself from GRC's Players window (right-click, Grant weapon…).
  2. Press Tab to open the chat bar and type /help.
  3. Type /where, walk a few steps, and type it again.
  4. Type /roll 20. Everyone in the level sees your bubble. Type it again right away to see the cooldown message.
  5. Type /nick Captain Hook, then /nick on its own to reset it.
  6. As a staff account, type /warp start 30 30. Try the same as a regular account and you are refused.

Next steps ​

  • /me <action>: set player.chat to *<nick> <action>* on the server.
  • /who: collect the players in the level. On the client you only see chat events, so do it on the server. Keep a Set of accounts updated by onPlayerJoined / onPlayerLeft.
  • Arguments with spaces: support quoted arguments (/warp "my level" 10 10) with a small tokenizer instead of split(/\s+/).
  • Command registry: replace the switch with a table of { name, usage, run } objects and generate /help from it.
  • Continue with HUD & message box to replace the reply line with proper UI.