Appearance
NPCs with a shared class
Build a small cast of villagers that wander around their spot, answer when players talk to them in chat, and say their lines when a player walks up and presses A. All of the behaviour lives in one class. Each NPC in the level is a few lines of configuration plus this.join('villager').
By the end you will have:
- Mira the baker, who strolls around the market and answers questions about bread,
- Bram the innkeeper, who stays behind his counter and, when asked about the merchant, spawns a travelling merchant that wanders for a minute and then leaves,
- a floating "[A] Talk" hint over whichever villager you are standing next to.
What you'll learn
- Writing a class with a serverside and a clientside half, and joining it from level NPCs with
this.join - Where per-NPC state lives (on
this), and why module variables don't work for it - Moving NPCs with
this.moveand chaining steps withonMovementFinishedand timers - Reacting to chat with
onPlayerChats - Player-to-NPC interaction with the clientside
triggerActionand a serversideonAction<Name>handler - Spawning temporary NPCs with
this.level.putnpc
The finished files are in docs/examples/npc-class/:
| File | Runs | Goes to |
|---|---|---|
classes/villager.ts | server | the Classes editor, villager entry, Serverside tab |
classes/villager.client.ts | every client | the same entry's Clientside tab |
npcs/baker.npc.ts | server | the Serverside tab of a level NPC |
npcs/innkeeper.npc.ts | server | the Serverside tab of another level NPC |
Why .npc.ts?
Level NPC scripts don't live in scripts/: they are stored inside the level file. In a real server you paste them into GRC's level editor (below). The .npc.ts suffix is a docs-only convention so these examples are type-checked against the NPC typings. See NPCs.
How it fits together
level NPC (baker) classes/villager.ts every client
───────────────── ─────────────────── ────────────
onCreated: onCreated: look, shape, wander villager.client.ts
this.villager = {...} ──► onMovementFinished (auto-joined)
this.join('villager') onPlayerChats ◄── typed chat "[A] Talk" hint
onActionTalk ◄──────────────── triggerAction(..., 'talk')A class is a script under scripts/classes/ whose exported functions are added to whatever script joins it. When an NPC joins villager:
- the class's handlers (
onPlayerChats,onMovementFinished, ...) fire for that NPC after the NPC's own handler of the same name ("both fire"), - the class's other functions become callable as
this.scheduleWander(),this.reply(...), - the class's
onCreatedruns immediately, withthis= the NPC, - the class's clientside half (
villager.client.ts) is joined on every client too.
Step 1: The class configuration
Every villager is described by a plain object that the level NPC stores on this.villager before joining. The class reads it in its own onCreated.
ts
interface VillagerConfig {
/** Shown in replies; players can greet the villager by name. */
name: string
head?: string
body?: string
/** Facing when standing still: 0 up, 1 left, 2 down, 3 right. */
dir?: number
/** How far (in tiles) the villager may stray from its spawn point. 0 = stands still. */
wander?: number
/** What the villager says when a player talks to it, in order. */
lines: string[]
/** Chat keyword -> reply. Matched case-insensitively anywhere in the message. */
replies?: Record<string, string>
}
const STEP = 2 // tiles per wander step (one body width)
const STEP_TIME = 0.5 // seconds per step
const HEARING = 8 // tiles: how close a player must be to be heard
const BUBBLE_MS = 4000 // how long a reply bubble stays up
const PAUSE_MS = 5000 // wandering pauses this long after a conversationThe config type is only for your editor. Serverside class files are checked against the plain server globals, so this is typed any inside a class, and the typed VillagerConfig local gives you autocompletion.
Step 2: Setting the NPC up in onCreated
ts
function onCreated() {
const c: VillagerConfig = this.villager
if (!c) {
echo(`[villager] ${this.name} joined without this.villager config`)
return
}
this.showCharacter()
this.head = c.head ?? 'head0.png'
this.body = c.body ?? 'body.png'
this.dir = c.dir ?? 2
// A character has no image, so give it a hitbox for triggerAction:
// its 2x2-tile body, in pixels.
this.setShape(0, 0, 32, 32)
// onCreated re-fires when the class is hot-reloaded, so keep the
// original spawn point and don't stack a second wander timer.
this.homeX ??= this.x
this.homeY ??= this.y
this.lineIndex ??= 0
this.busyUntil = 0
if (c.wander) this.scheduleWander()
}Three things here are easy to get wrong:
- State goes on
this. A module-levellet homeXwould be one variable shared by every villager: class modules are loaded once, and all joiners see the same top-level state. Unknown properties on an NPC'sthisform a per-NPC state bag that persists between handler calls. - Characters need a shape.
showCharacter()draws the NPC as a gani character, but an NPC without an image has no hitbox, sotriggerActioncan't hit it.setShape(0, 0, 32, 32)gives it the 2×2-tile body (in pixels). A shaped character NPC also blocks players by default (dontblock()turns that off). onCreatedruns again on hot reload. Saving the class in GRC re-fires itsonCreatedon every live joiner.??=keeps the original home position, andscheduleWander(next step) always clears the previous timer, so a reload never leaves two wander loops running.
Step 3: Wandering
ts
/** Queues the next wander step in 2-5 seconds (replacing any queued one). */
function scheduleWander() {
if (this.wanderTimer) clearTimeout(this.wanderTimer)
this.wanderTimer = setTimeout(() => this.wanderStep(), 2000 + Math.random() * 3000)
}
function wanderStep() {
this.wanderTimer = 0
const c: VillagerConfig = this.villager
// Mid-conversation, or just not in the mood: try again later.
if (Date.now() < this.busyUntil || Math.random() < 0.3) {
this.scheduleWander()
return
}
const d = Math.floor(Math.random() * 4) // 0 up, 1 left, 2 down, 3 right
const dx = [0, -STEP, 0, STEP][d]
const dy = [-STEP, 0, STEP, 0][d]
// Stay within the leash around the spawn point.
const reach = c.wander ?? 0
if (Math.abs(this.x + dx - this.homeX) > reach || Math.abs(this.y + dy - this.homeY) > reach) {
this.scheduleWander()
return
}
this.setAni('walk')
// 4 = stop at walls/NPCs/players, 8 = fire onMovementFinished,
// 16 = face the direction of travel.
this.move(dx, dy, STEP_TIME, 4 | 8 | 16)
}
function onMovementFinished() {
this.setAni('idle')
this.scheduleWander()
}this.move(dx, dy, time, options) glides the NPC by (dx, dy) tiles. The server sends the move once and every client animates it smoothly on its own, which is far cheaper than assigning this.x every tick. The options bitmask does the rest:
| Flag | Meaning |
|---|---|
4 | blockcheck: stop at the last clear spot instead of walking into walls, other NPCs or players |
8 | fire onMovementFinished when the move ends (reached or blocked) |
16 | turn to face the direction of travel |
The loop is a chain: scheduleWander → (2–5 s) → wanderStep → move → onMovementFinished → scheduleWander. Serverside NPC timers (setTimeout) are per-NPC: the arrow callback keeps this bound to the NPC, and the timer dies with the NPC when its level is reloaded.
Calling your own helpers
this.wanderStep() works because a class's exported functions are callable on the joiner's this (top-level functions are exported automatically, with or without export). Inside a top-level function, a bare helper() call would also see the NPC as this, but this.helper() reads more clearly in a class.
Step 4: Answering chat
ts
function onPlayerChats(player: Player, chat: string) {
const c: VillagerConfig = this.villager
if (!c || Math.hypot(player.x - this.x, player.y - this.y) > HEARING)
return
const text = chat.toLowerCase()
for (const [keyword, reply] of Object.entries(c.replies ?? {})) {
if (text.includes(keyword.toLowerCase())) {
this.reply(player, reply)
return
}
}
if (text.includes(c.name.toLowerCase()) || /\b(hi|hello|hey)\b/.test(text))
this.reply(player, `Hello, ${player.nick}! I'm ${c.name}.`)
}Serverside onPlayerChats(player, chat) fires on every NPC in the level whenever a player types chat (chat set by scripts doesn't trigger it). The class ignores players more than 8 tiles away, then checks the NPC's keyword table, then answers greetings. player.nick is the player's display name.
Step 5: Talking with the A key
Interaction is a two-part handshake. Clientside, the class's other half notices the key press and asks the server to trigger an action at a point. Serverside, the server hit-tests its NPC shapes at that point and calls onAction<Name>(player, ...) on each NPC it finds.
The serverside handler
ts
/** Fired by triggerAction(x, y, 'talk') landing on this NPC's shape. */
function onActionTalk(player: Player) {
const c: VillagerConfig = this.villager
if (!c || c.lines.length === 0) return
this.reply(player, c.lines[this.lineIndex % c.lines.length])
this.lineIndex++
}
/** Turns toward the player, shows a chat bubble and pauses wandering. */
function reply(player: Player, text: string) {
const dx = player.x - this.x, dy = player.y - this.y
this.dir = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? 1 : 3) : (dy < 0 ? 0 : 2)
this.chat = text
this.busyUntil = Date.now() + PAUSE_MS
if (this.chatTimer) clearTimeout(this.chatTimer)
this.chatTimer = setTimeout(() => { this.chat = '' }, BUBBLE_MS)
}triggerAction(x, y, 'talk') becomes onActionTalk: the action name with its first letter uppercased. The triggering player comes first in the arguments. reply is a shared helper: it turns the villager toward the player (dir: 0 up, 1 left, 2 down, 3 right), sets the chat bubble, pauses wandering and clears the bubble after 4 seconds.
The clientside half
Create classes/villager.client.ts. Because the level NPCs join villager on the server, every client joins this half to its copy of each villager automatically. The level NPCs don't need a clientside script at all.
ts
const TALK_RANGE = 3 // tiles between the player's and the villager's top-left corners
function isNear(npc: any): boolean {
return Math.hypot(player.x - npc.x, player.y - npc.y) <= TALK_RANGE
}
function onCreated() {
const hint = findimg(1)
hint.text = '[A] Talk'
hint.style = 'bc' // bold, centered on x
hint.fontsize = 12
hint.textshadow = true
hint.layer = 5 // above players
hint.visible = false
}
function onUpdate() {
// Images are world-space by default: x/y are tiles. Follow the NPC,
// which may be gliding along a move().
const hint = findimg(1)
hint.x = this.x + 1 // horizontal center of the 2-tile body
hint.y = this.y - 1.2 // just above the head
hint.visible = isNear(this)
}In clientside NPC code this is this client's copy of the NPC. Reading this.x/this.y gives the interpolated position even mid-move. The hint is a world-space findimg text image, and image ids are per script, so each villager has its own findimg(1).
ts
function onKeyPressed(key: string) {
if (key !== 'A' || !isNear(this))
return
// Aim at the middle of the villager's 2x2 body; the server hit-tests its
// own copy of the shape and fires the serverside onActionTalk(player).
triggerAction(this.x + 1, this.y + 1, 'talk')
}onKeyPressed fires once per key press with letters uppercased. The target point is the middle of the villager's body, well inside the 32×32 shape set in step 2.
triggerAction and the class typings
triggerAction is a runtime global in every clientside script, but only the clientside NPC typings (npc.client.d.ts) declare it. Class files are checked with the weapon typings, so villager.client.ts declares it itself (the same trick works in any weapon that needs it).
Step 6: The level NPCs
Now give the class some villagers. Each level NPC's serverside script only fills in this.villager and joins:
ts
// Serverside script of a level NPC (paste into the NPC's "Serverside" tab in
// GRC's level editor). All behaviour comes from the villager class; this NPC
// only describes who it is.
function onCreated(this: NpcThis) {
this.villager = {
name: 'Mira',
head: 'head3.png',
body: 'body2.png',
wander: 6,
lines: [
'Fresh bread, straight from the oven!',
'I get up before the sun to bake.',
'Tell the innkeeper his rolls are ready.',
],
replies: {
bread: 'Rye, wheat or sourdough? Everything is baked today.',
price: 'Two coins a loaf, one for day-old.',
},
}
this.join('villager')
}The this: NpcThis annotation is optional. It types this as the serverside NpcThis instead of any.
Placing them in GRC
- Open the level in GRC's level editor and pick the NPC tool.
- Right-click a free spot to place a new NPC, then double-click it to open Edit NPC. Leave Image empty.
- Paste
baker.npc.tsinto the Serverside tab. Leave Clientside empty. - Save the level. Saving hot-reloads the level: its NPCs are recreated and every player in it sees the change straight away.
classes/villager.ts and villager.client.ts go into GRC's Classes editor, like any other script (the Classes window in GRC, or your editor plus a reload).
Step 7: A second NPC that spawns a visitor
The innkeeper joins the same class but stands still (wander: 0):
ts
function onCreated(this: NpcThis) {
this.villager = {
name: 'Bram',
head: 'head7.png',
body: 'body4.png',
dir: 2,
wander: 0, // stays behind the counter
lines: [
'Welcome to the Sleepy Boar.',
'A travelling merchant passes through now and then. Ask me about the merchant!',
],
replies: {
room: 'Rooms are upstairs. Mind the third step.',
},
}
this.join('villager')
}It also has a handler of its own, in the same script below onCreated:
ts
// The spawned NPC's serverside script, as TypeScript SOURCE. It joins the
// same class and removes itself after a minute; local NPCs are never saved
// to the level file.
const MERCHANT_SCRIPT = `
function onCreated() {
this.villager = ${JSON.stringify({
name: 'Oskar',
head: 'head12.png',
body: 'body5.png',
wander: 4,
lines: ['Silks from the east! Spices from the south!', 'Only here for a moment, friend.'],
})}
this.join('villager')
setTimeout(() => {
this.chat = 'Time to hit the road!'
setTimeout(() => this.destroy(), 2000)
}, 60000)
}
`
// The NPC's own handler runs first, then the class's onPlayerChats.
function onPlayerChats(this: NpcThis, player: Player, chat: string) {
if (!chat.toLowerCase().includes('merchant'))
return
if (Date.now() < (this.merchantUntil ?? 0))
return // one merchant at a time
const merchant = this.level.putnpc(this.x + 4, this.y + 2, MERCHANT_SCRIPT)
if (merchant) {
this.merchantUntil = Date.now() + 62000
merchant.chat = 'Did someone call for a merchant?'
}
}- Both handlers fire. On every chat line, the innkeeper's own
onPlayerChatsruns first, then the class's. Bram spawns the merchant on "merchant" and the class still answers "hello Bram". this.level.putnpc(x, y, serverScript)creates a local NPC from TypeScript source. The source can join classes like any level NPC. The first compile of a new source briefly stalls the server tick, and identical sources are cached, which is whyMERCHANT_SCRIPTis a constant.- putnpc returns the new NPC's handle. Real props like
chat,xorheadcan be set on it directly (the NPC's own state-bag properties are not shared with the handle). - Local NPCs are never saved into the level. They survive level hot reloads and disappear on a server restart, on
destroy(), or when staff run/clearnpcs <level>in RC. This merchant destroys itself after a minute.
Don't spawn from onCreated
A level NPC's onCreated runs again on every level hot reload, but local NPCs survive the reload, so spawning there piles up duplicates. Spawn from events (like this chat keyword) and give spawned NPCs a way to leave.
Complete files
Every file from this tutorial, in full, for copying into GRC.
classes/villager.client.ts
ts
// Clientside half of the villager class. It is joined automatically on every
// client whenever a serverside script joins 'villager', so level NPCs don't
// need a clientside script of their own.
//
// Shows a "[A] Talk" hint over the villager while the local player stands
// next to it, and turns the A key into a talk request.
// triggerAction is an engine global in every clientside script, but only the
// NPC typings declare it; class files are checked with the weapon typings.
declare function triggerAction(x: number, y: number, action: string, ...params: any[]): void
const TALK_RANGE = 3 // tiles between the player's and the villager's top-left corners
function isNear(npc: any): boolean {
return Math.hypot(player.x - npc.x, player.y - npc.y) <= TALK_RANGE
}
function onCreated() {
const hint = findimg(1)
hint.text = '[A] Talk'
hint.style = 'bc' // bold, centered on x
hint.fontsize = 12
hint.textshadow = true
hint.layer = 5 // above players
hint.visible = false
}
function onUpdate() {
// Images are world-space by default: x/y are tiles. Follow the NPC,
// which may be gliding along a move().
const hint = findimg(1)
hint.x = this.x + 1 // horizontal center of the 2-tile body
hint.y = this.y - 1.2 // just above the head
hint.visible = isNear(this)
}
function onKeyPressed(key: string) {
if (key !== 'A' || !isNear(this))
return
// Aim at the middle of the villager's 2x2 body; the server hit-tests its
// own copy of the shape and fires the serverside onActionTalk(player).
triggerAction(this.x + 1, this.y + 1, 'talk')
}classes/villager.ts
ts
// Shared behaviour for every villager NPC: wandering, chat replies and
// "talk" interactions. A level NPC only describes itself and joins:
//
// function onCreated() {
// this.villager = { name: 'Mira', head: 'head3.png', lines: ['Hi!'] }
// this.join('villager')
// }
//
// Everything below runs with `this` bound to the joining NPC. Module-level
// variables would be SHARED by every villager, so per-NPC state lives on
// `this` instead.
interface VillagerConfig {
/** Shown in replies; players can greet the villager by name. */
name: string
head?: string
body?: string
/** Facing when standing still: 0 up, 1 left, 2 down, 3 right. */
dir?: number
/** How far (in tiles) the villager may stray from its spawn point. 0 = stands still. */
wander?: number
/** What the villager says when a player talks to it, in order. */
lines: string[]
/** Chat keyword -> reply. Matched case-insensitively anywhere in the message. */
replies?: Record<string, string>
}
const STEP = 2 // tiles per wander step (one body width)
const STEP_TIME = 0.5 // seconds per step
const HEARING = 8 // tiles: how close a player must be to be heard
const BUBBLE_MS = 4000 // how long a reply bubble stays up
const PAUSE_MS = 5000 // wandering pauses this long after a conversation
function onCreated() {
const c: VillagerConfig = this.villager
if (!c) {
echo(`[villager] ${this.name} joined without this.villager config`)
return
}
this.showCharacter()
this.head = c.head ?? 'head0.png'
this.body = c.body ?? 'body.png'
this.dir = c.dir ?? 2
// A character has no image, so give it a hitbox for triggerAction:
// its 2x2-tile body, in pixels.
this.setShape(0, 0, 32, 32)
// onCreated re-fires when the class is hot-reloaded, so keep the
// original spawn point and don't stack a second wander timer.
this.homeX ??= this.x
this.homeY ??= this.y
this.lineIndex ??= 0
this.busyUntil = 0
if (c.wander) this.scheduleWander()
}
/** Queues the next wander step in 2-5 seconds (replacing any queued one). */
function scheduleWander() {
if (this.wanderTimer) clearTimeout(this.wanderTimer)
this.wanderTimer = setTimeout(() => this.wanderStep(), 2000 + Math.random() * 3000)
}
function wanderStep() {
this.wanderTimer = 0
const c: VillagerConfig = this.villager
// Mid-conversation, or just not in the mood: try again later.
if (Date.now() < this.busyUntil || Math.random() < 0.3) {
this.scheduleWander()
return
}
const d = Math.floor(Math.random() * 4) // 0 up, 1 left, 2 down, 3 right
const dx = [0, -STEP, 0, STEP][d]
const dy = [-STEP, 0, STEP, 0][d]
// Stay within the leash around the spawn point.
const reach = c.wander ?? 0
if (Math.abs(this.x + dx - this.homeX) > reach || Math.abs(this.y + dy - this.homeY) > reach) {
this.scheduleWander()
return
}
this.setAni('walk')
// 4 = stop at walls/NPCs/players, 8 = fire onMovementFinished,
// 16 = face the direction of travel.
this.move(dx, dy, STEP_TIME, 4 | 8 | 16)
}
function onMovementFinished() {
this.setAni('idle')
this.scheduleWander()
}
function onPlayerChats(player: Player, chat: string) {
const c: VillagerConfig = this.villager
if (!c || Math.hypot(player.x - this.x, player.y - this.y) > HEARING)
return
const text = chat.toLowerCase()
for (const [keyword, reply] of Object.entries(c.replies ?? {})) {
if (text.includes(keyword.toLowerCase())) {
this.reply(player, reply)
return
}
}
if (text.includes(c.name.toLowerCase()) || /\b(hi|hello|hey)\b/.test(text))
this.reply(player, `Hello, ${player.nick}! I'm ${c.name}.`)
}
/** Fired by triggerAction(x, y, 'talk') landing on this NPC's shape. */
function onActionTalk(player: Player) {
const c: VillagerConfig = this.villager
if (!c || c.lines.length === 0) return
this.reply(player, c.lines[this.lineIndex % c.lines.length])
this.lineIndex++
}
/** Turns toward the player, shows a chat bubble and pauses wandering. */
function reply(player: Player, text: string) {
const dx = player.x - this.x, dy = player.y - this.y
this.dir = Math.abs(dx) > Math.abs(dy) ? (dx < 0 ? 1 : 3) : (dy < 0 ? 0 : 2)
this.chat = text
this.busyUntil = Date.now() + PAUSE_MS
if (this.chatTimer) clearTimeout(this.chatTimer)
this.chatTimer = setTimeout(() => { this.chat = '' }, BUBBLE_MS)
}npcs/innkeeper.npc.ts
ts
// Serverside script of a second level NPC. It joins the same class, but
// stands still and adds one behaviour of its own: asked about the merchant,
// it spawns a temporary villager with level.putnpc.
function onCreated(this: NpcThis) {
this.villager = {
name: 'Bram',
head: 'head7.png',
body: 'body4.png',
dir: 2,
wander: 0, // stays behind the counter
lines: [
'Welcome to the Sleepy Boar.',
'A travelling merchant passes through now and then. Ask me about the merchant!',
],
replies: {
room: 'Rooms are upstairs. Mind the third step.',
},
}
this.join('villager')
}
// The spawned NPC's serverside script, as TypeScript SOURCE. It joins the
// same class and removes itself after a minute; local NPCs are never saved
// to the level file.
const MERCHANT_SCRIPT = `
function onCreated() {
this.villager = ${JSON.stringify({
name: 'Oskar',
head: 'head12.png',
body: 'body5.png',
wander: 4,
lines: ['Silks from the east! Spices from the south!', 'Only here for a moment, friend.'],
})}
this.join('villager')
setTimeout(() => {
this.chat = 'Time to hit the road!'
setTimeout(() => this.destroy(), 2000)
}, 60000)
}
`
// The NPC's own handler runs first, then the class's onPlayerChats.
function onPlayerChats(this: NpcThis, player: Player, chat: string) {
if (!chat.toLowerCase().includes('merchant'))
return
if (Date.now() < (this.merchantUntil ?? 0))
return // one merchant at a time
const merchant = this.level.putnpc(this.x + 4, this.y + 2, MERCHANT_SCRIPT)
if (merchant) {
this.merchantUntil = Date.now() + 62000
merchant.chat = 'Did someone call for a merchant?'
}
}Try it
- Create
villager(both halves) in GRC's Classes editor and save. - Place two NPCs as in step 6, one with
baker.npc.tsand one withinnkeeper.npc.ts, and save the level. - Walk into the level. Mira starts pacing around her spot. Bram stays put.
- Stand next to Mira: [A] Talk appears over her head. Press A a few times to hear her lines in order.
- Type
hello Miraandhow much is the bread?. She turns to you and answers. - Type
merchantnear Bram. Oskar appears, wanders for a minute, says goodbye and vanishes. - Edit a line in
villager.tsand reload the class. The villagers keep walking (no doubled loops), and the new line is used immediately.
If an NPC stands still with no bubble, check the server log in GRC: the class logs joined without this.villager config when the config is set after join.
Next steps
- Schedules: read
serverrtime (see the day/night tutorial) inwanderStepand send villagers home at night. - Dialog UI: instead of a chat bubble, hand the line to a weapon's serverside half with
findweapon(name)?.trigger(...)fromonActionTalk, and let ittriggerClientthe text into a GUI message box (see HUD & message box). - Shopkeepers: add a
shopfield to the config and open the shop weapon fromonActionTalk. - Clientside flair: give
villager.client.tsanonPlayerEntershandler that waves (this.setAni(...), local to that client) when the local player arrives.