Skip to content

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.move and chaining steps with onMovementFinished and timers
  • Reacting to chat with onPlayerChats
  • Player-to-NPC interaction with the clientside triggerAction and a serverside onAction<Name> handler
  • Spawning temporary NPCs with this.level.putnpc

The finished files are in docs/examples/npc-class/:

FileRunsGoes to
classes/villager.tsserverthe Classes editor, villager entry, Serverside tab
classes/villager.client.tsevery clientthe same entry's Clientside tab
npcs/baker.npc.tsserverthe Serverside tab of a level NPC
npcs/innkeeper.npc.tsserverthe 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 onCreated runs immediately, with this = 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 conversation

The 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-level let homeX would 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's this form 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, so triggerAction can'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).
  • onCreated runs again on hot reload. Saving the class in GRC re-fires its onCreated on every live joiner. ??= keeps the original home position, and scheduleWander (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:

FlagMeaning
4blockcheck: stop at the last clear spot instead of walking into walls, other NPCs or players
8fire onMovementFinished when the move ends (reached or blocked)
16turn 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 ​

  1. Open the level in GRC's level editor and pick the NPC tool.
  2. Right-click a free spot to place a new NPC, then double-click it to open Edit NPC. Leave Image empty.
  3. Paste baker.npc.ts into the Serverside tab. Leave Clientside empty.
  4. 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 onPlayerChats runs 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 why MERCHANT_SCRIPT is a constant.
  • putnpc returns the new NPC's handle. Real props like chat, x or head can 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 ​

  1. Create villager (both halves) in GRC's Classes editor and save.
  2. Place two NPCs as in step 6, one with baker.npc.ts and one with innkeeper.npc.ts, and save the level.
  3. Walk into the level. Mira starts pacing around her spot. Bram stays put.
  4. Stand next to Mira: [A] Talk appears over her head. Press A a few times to hear her lines in order.
  5. Type hello Mira and how much is the bread?. She turns to you and answers.
  6. Type merchant near Bram. Oskar appears, wanders for a minute, says goodbye and vanishes.
  7. Edit a line in villager.ts and 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 serverr time (see the day/night tutorial) in wanderStep and 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(...) from onActionTalk, and let it triggerClient the text into a GUI message box (see HUD & message box).
  • Shopkeepers: add a shop field to the config and open the shop weapon from onActionTalk.
  • Clientside flair: give villager.client.ts an onPlayerEnters handler that waves (this.setAni(...), local to that client) when the local player arrives.