Skip to content

NPCs ​

NPCs are the objects that live in a level: signs, shopkeepers, doors, wandering townsfolk, chests. Unlike weapons, which belong to a player and follow them around, an NPC belongs to a level and is seen by everyone standing in it.

Every NPC can carry two scripts:

HalfRuns onthis isWrites go to
Serversidethe server, onceNpcThis (server)everyone in the level (server-authoritative)
Clientsideevery client in the level, separatelyNpcThis (client)this client only (never sent anywhere)

Put game logic — who can open the door, what the shop sells, where the NPC walks — in the serverside half. Use the clientside half for things only one player should see or that react to local input: a light glow, a hover hint, a key press that asks the server to do something.

Where NPC scripts live ​

Level NPCs are baked into the level file (.glvl), scripts included. You create and edit them in GRC's level editor:

  1. Open the level, pick the NPC tool and right-click to place a new NPC at the cursor tile.
  2. Double-click the NPC to open its editor: image, X/Y (tiles) and two script tabs, Clientside and Serverside. Each tab has full IntelliSense for its side.
  3. Save the dialog (Ctrl+S), then save the level. Saving the level uploads it and hot-reloads every NPC in it (see Hot reload).

Both halves are ordinary TypeScript modules: export is optional (every top-level function is a handler), they can join classes and import shared lib modules. The serverside half sees the server globals (echo, triggerClient, flags, timers, …) plus npc.server.d.ts; the clientside half sees the client globals (player is still the local player, findimg, keydown, …) plus npc.client.d.ts.

Keep level scripts tiny

Because the source is inside the level file, the same behaviour copied into twenty NPCs is twenty copies to maintain. Put the behaviour in a class and let each NPC only set its configuration and this.join('name') — see Tutorial 5.

Lifecycle ​

A level's NPCs are instantiated the first time a player enters the level (for a gmap, when the member level first comes into a player's view). A level nobody has visited has no running NPCs. Once activated, its serverside NPCs keep running — onUpdate and timers included — whether or not anyone is still in the level, like in Graal.

Clientside halves are created when the NPC appears on a client (you entered the level) and unloaded when you leave it.

Events ​

Handlers are exported functions; this is the NPC. Use function declarations, not arrow functions, for handlers (arrow functions inside a handler are fine and keep this).

Serverside ​

HandlerFires when
onCreated()The NPC was instantiated (level activated) or the level file was updated.
onPlayerEnters(player)A Player entered the NPC's level (again after a hot reload).
onPlayerChats(player, chat)A player in the level typed chat. Script-set chat doesn't fire it.
onUpdate(dt)Every server tick (20 per second); dt in seconds.
onAction<Name>(player, ...params)A clientside triggerAction hit this NPC's shape.
onAction<Name>(...params)A serverside this.level.triggerAction hit it (no player argument).
onShot(data) / onShotAt(x, y, data)Projectiles — see Projectiles.
onMovementFinished()A this.move() with option 8 ended.

Clientside ​

HandlerFires when
onCreated()The NPC appeared on this client (level entered, or hot reload).
onPlayerEnters(player)A player entered — the local player too, right after onCreated. Receives a ChatPlayer.
onPlayerChats(player, chat)A player chatted. /-commands arrive here too.
onUpdate(dt)Every frame.
onKeyPressed(key)A key went down (once per press), same key names as weapons.
onAction<Name>(...params)A serverside level.triggerAction hit this NPC.
onPMReceived(sender)A PM arrived for the local player.
onShot(data) / onShotAt(x, y, data)Projectiles.
onMovementFinished()A clientside this.move() with option 8 ended.

setTimeout, setInterval and sleep work per NPC: callbacks keep this bound to the NPC, and the timers die when the NPC unloads (hot reload, or on the client when you leave the level). See the execution model.

The serverside this ​

Writes to the known properties of the serverside NpcThis replicate to everyone in the level:

ts
export function onCreated() {
    this.image = 'images/sign.png'   // '' = invisible
    this.chat = 'Welcome to town!'   // '' clears the bubble
    this.dir = 2                     // 0 up, 1 left, 2 down, 3 right
}
MemberWhat it does
id, nameRuntime id; npc-<index> for level NPCs, localnpc-<id> for local NPCs.
x, yPosition in tiles. Assigning teleports and cancels any move().
dirFacing, Graal convention.
image, chatSprite and chat bubble.
showCharacter()Draw the NPC as a gani character instead of an image.
head, body, colors, ani, setAni()Character appearance; only visible after showCharacter().
setShape()The hitbox for triggerAction and projectiles.
dontblock(), blockagain(), blockingCollision, see Blocking.
move()Smooth movement, see Movement.
levelThe NPC's NpcLevel.
join(), leave(), joinedclassesClasses.
destroy()Remove a local NPC (returns false for level NPCs).

A character NPC:

ts
export function onCreated() {
    this.showCharacter()
    this.head = 'head3.png'
    this.body = 'body2.png'
    this.colors[1] = '255,0,0'   // coat; [0] skin, [2] sleeves, [3] shoes, [4] belt
    this.dir = 2
    this.setShape(0, 0, 32, 32)  // 2×2 tiles
}

Per-NPC state ​

Any property that isn't part of the API is a state bag private to this NPC and kept between handler calls — Graal's "state on this" convention:

ts
export function onActionTalk(player: Player) {
    this.talks = (this.talks ?? 0) + 1
    this.chat = `${player.nick}, you've talked to me ${this.talks} times`
}

State-bag properties are not replicated and are not shared between the server and client halves; they are also lost when the NPC is re-created by a hot reload.

The clientside this ​

The clientside NpcThis has the same shape (x, y, dir, image, chat, head, body, colors, ani, showCharacter(), setAni(), move(), setShape(), dontblock(), …) but every write is local to this client: it changes what this player sees and nothing else. The server's copy stays the authority — a later serverside write or move overrides a client-local change.

The clientside half also owns the NPC's light: drawaslight() with lightcolor, lightradius, lightshape and friends — see Lighting.

Client setShape doesn't change what triggers hit

The server hit-tests its own shapes for triggerAction and projectiles. Call setShape in the serverside half.

triggerAction: talking to an NPC ​

triggerAction(x, y, action, ...params) addresses NPCs by position: it fires onAction<Name> on every NPC whose shape contains the tile point (x, y). The action name gets its first letter uppercased ('openDoor' → onActionOpenDoor); characters other than letters, digits and _ are dropped.

Routing is strict, like Graal:

  • Clientside triggerAction sends the request to the server, which hit-tests its shapes and runs the serverside handler as onAction<Name>(player, ...params). Nothing runs clientside.
  • Serverside this.level.triggerAction runs serverside handlers as onAction<Name>(...params) (no player) and the clientside handler of every hit NPC on every client in the level.

A "press A in front of an NPC" interaction, split across the two halves:

ts
// Clientside half: ask the server to act on whatever is in front of us.
const AHEAD = { up: [0, -2], left: [-2, 0], down: [0, 2], right: [2, 0] }

export function onKeyPressed(key: string) {
    if (key !== 'A') return
    const [dx, dy] = AHEAD[player.dir]
    // player.x/y is the top-left of the 2×2 body; aim past its center.
    triggerAction(player.x + 1 + dx, player.y + 1 + dy, 'talk')
}
ts
// Serverside half of the target NPC.
export function onActionTalk(player: Player) {
    this.chat = `Hello, ${player.nick}!`
}

Params are JSON round-tripped, so only pass plain data.

Shapes

An NPC's shape defaults to its image's pixel size. An NPC with no image and no setShape has no shape, so no action or projectile can land on it — invisible "talk spots" (signs baked into the tileset, PCs) need an explicit this.setShape(0, 0, w, h). Shape values are pixels relative to the NPC's top-left; a tile is 16 pixels.

Calling triggerAction from a weapon

triggerAction is declared only for clientside NPC scripts, but the engine installs it as a global for every clientside script. A clientside weapon can use it after declaring it:

ts
declare function triggerAction(x: number, y: number, action: string, ...params: any[]): void

NpcLevel ​

this.level in a serverside NPC is an NpcLevel: the NPC's own level, in that level's local tile coordinates.

MemberUse
nameThe level's file name.
triggerAction()Fire actions at shapes (server + client handlers).
onwall()Is a rectangle blocked — by tiles, other blocking NPCs or players' lower bodies (never this NPC)? Out-of-level counts as blocked.
tiletype()The collision type of a tile.
shoot()Spawn a projectile (Projectiles).
putnpc()Spawn a local NPC.

level vs this.level

The bare global level is player-scoped, meant for weapon and server scripts. Inside an NPC script always use this.level — level.shoot/level.putnpc return null there.

Movement ​

this.move(dx, dy, time, options) glides the NPC by (dx, dy) tiles over time seconds. The server sends the move once and every client animates it on its own, so it is much cheaper than assigning x/y every tick. While a move runs, x/y read the interpolated position.

options is a bitmask:

BitNameEffect
1cacheQueue behind pending moves instead of replacing them.
2appendMeasure dx/dy from the end of the queued path.
4blockcheckStop at the last clear spot before a wall; drops the rest of the queue.
8notifyFire onMovementFinished when this move ends (reached or blocked).
16apply directionFace the move's dominant axis when it starts.

time <= 0 moves instantly on the next update; assigning x or y cancels every pending move. A wandering NPC:

ts
export function onCreated() {
    this.showCharacter()
    this.homeX = this.x
    this.homeY = this.y
    // Hot reload re-fires onCreated: don't stack intervals.
    if (this.wanderTimer) clearInterval(this.wanderTimer)
    this.wanderTimer = setInterval(() => this.wander(), 2500)
}

export function wander() {
    const d = Math.floor(Math.random() * 4)
    const dx = [0, -2, 0, 2][d], dy = [-2, 0, 2, 0][d]
    const nx = this.x + dx, ny = this.y + dy
    if (Math.abs(nx - this.homeX) > 4 || Math.abs(ny - this.homeY) > 4) return
    // The strip swept by the lower body; walls, NPCs and players block.
    if (this.level.onwall(Math.min(this.x, nx), Math.min(this.y, ny) + 1,
                          2 + Math.abs(dx), 1 + Math.abs(dy))) return
    this.dir = d
    this.setAni('walk')
    this.move(dx, dy, 0.35, 8)
}

export function onMovementFinished() {
    this.setAni('idle')
}

A clientside this.move() moves this client's copy only; its onMovementFinished fires on the client. Moves started serverside fire onMovementFinished serverside only.

Blocking ​

NPCs block by default, like walls: players can't walk through them, and blockcheck moves and onwall queries see them. What blocks:

  • a character NPC (after showCharacter()) blocks with its lower body — the same 2×1 footprint a player walks with;
  • anything else blocks with its shape (or image);
  • an NPC with neither an image nor a shape never blocks.

Call this.dontblock() to let players and other NPCs walk through it (floor decals, ghosts, pickups) and this.blockagain() to undo it; this.blocking reads the current state. Serverside calls apply to everyone; the clientside dontblock() only affects the local player and that client's own NPC moves, and is overridden by the server's next change.

Projectiles and lights ignore NPCs

Projectile flight and light propagation test tiles only — a projectile flies through a blocking NPC unless it hits the NPC's shape (which fires onShot). Custom movement scripts that use onwall need their own escape rule for when an NPC ends up standing on the player.

Local NPCs (putnpc) ​

this.level.putnpc(x, y, serverScript, clientScript?) spawns a local NPC at runtime — Graal's putnpc2. Weapon and server scripts have the same thing on the player-scoped level.putnpc and on any player.level (gmap-global coordinates when on a gmap).

The scripts are TypeScript source strings, compiled through the same pipeline as level NPCs (identical sources are cached; the first compile of new source briefly blocks the server tick). The source can join a class, which keeps the string short:

ts
// In a serverside weapon handler: drop a flare that removes itself.
const npc = level.putnpc(player.x, player.y, `
    export function onCreated() { this.join('flare') }
`)
ts
// classes/flare.ts — serverside half
export function onCreated() {
    setTimeout(() => this.destroy(), 100)
}

Local NPCs are never saved to the level file. They live until destroy() or a server restart, and they survive level hot reloads. Staff with the clearnpcs right can remove strays with /clearnpcs <level> in RC.

putnpc returns the NPC's handle — the same API as its this — or null when the level can't be resolved. Set real properties on it directly:

ts
const n = this.level.putnpc(10, 12, 'export function onCreated() {}')
if (n) { n.image = 'images/chest.png'; n.chat = 'Loot!' }

The handle and the script's this are different objects

Engine properties (x, image, chat, …) are shared, but state-bag properties are not: n.reward = 50 on the handle is invisible to the new NPC's script. The NPC's onCreated has also already run by the time putnpc returns. Bake configuration into the source instead:

ts
const cfg = JSON.stringify({ reward: 50 })
this.level.putnpc(10, 12, `
    export function onCreated() { this.cfg = ${cfg}; this.join('chest') }
`)

Hot reload ​

Saving a level (GRC level editor, or any upload of the .glvl) hot-reloads it:

  • every level NPC is unloaded — its timers die — and re-created, so serverside onCreated runs again and onPlayerEnters fires for everyone already in the level;
  • clients re-enter the level and get fresh copies; their clientside onCreated runs again;
  • local NPCs are kept.

State-bag properties don't survive the re-creation. Joined classes are re-joined because onCreated runs again — keep join() calls in onCreated.

Lib edits and NPCs

NPC scripts are compiled when their level is activated or reloaded. Editing a lib module or reloading scripts from RC does not recompile NPCs that are already running; re-save the level in GRC's Level Editor to pick up the change. Class edits, by contrast, are rebound live.

See also ​