Appearance
1. Chat commands
In this tutorial you build a commands weapon that adds slash commands to the chat bar:
/helplists the commands./whereprints 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./nickon 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,/nickor/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 = trueswitches the image to screen pixels (ScriptImage.screen). Setting a non-emptytextdraws text instead of an image.sayis a plain helper, and it can still usethis. Inside any top-level function,thisis the weapon, even when a handler calls the helper directly assay(...).this.replyLeftis ordinary weapon state that is kept between calls (WeaponThis).- The fade-out runs in
onUpdate, not in asetTimeout.findimgonly works inside handlers, so a timer callback couldn't touch the image.hideimgdestroys 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.chaton 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 throughtriggerClient. - The cooldown lives in a module-level
Mapkeyed byplayer.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.onPlayerLeftcleans 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
staffinserveroptions.jsonthat 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
- Create both halves of
commandsin GRC's Weapons editor and save, then grant it to yourself from GRC's Players window (right-click, Grant weapon…). - Press Tab to open the chat bar and type
/help. - Type
/where, walk a few steps, and type it again. - Type
/roll 20. Everyone in the level sees your bubble. Type it again right away to see the cooldown message. - Type
/nick Captain Hook, then/nickon its own to reset it. - As a staff account, type
/warp start 30 30. Try the same as a regular account and you are refused.
Next steps
/me <action>: setplayer.chatto*<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 aSetof accounts updated byonPlayerJoined/onPlayerLeft.- Arguments with spaces: support quoted arguments (
/warp "my level" 10 10) with a small tokenizer instead ofsplit(/\s+/). - Command registry: replace the
switchwith a table of{ name, usage, run }objects and generate/helpfrom it. - Continue with HUD & message box to replace the reply line with proper UI.