Skip to content

3. Shop & inventory ​

This tutorial builds a small economy from five pieces:

  • an item catalog stored in a SQLite database, which staff can edit in GRC's SQL Explorer;
  • a per-player bag, stored in a replicated collection, so each client always has a live copy of its own items;
  • a shop weapon: a window listing the catalog, with a quantity field and a Buy button;
  • a shopkeeper NPC that opens the shop when you say "shop" next to it or press A;
  • an inventory weapon (press I) with a Use button for each item. Using an item runs that item's own script: a potion heals you, and you'll see it on the HUD from tutorial 2.

Gold is clientr.gold, the same flag the HUD displays.

What you'll learn

  • Reading a catalog from SQLite with opendatabase, and why you load it into memory.
  • Storing per-player records in a collection and reading its live mirror on the client (ClientCollection).
  • Sharing types and helpers between server and client with a lib/ module.
  • A shop GUI: scroll lists, selection, a text field with .on('keydown').
  • NPC scripts on both sides, onPlayerChats, and triggerAction.
  • Calling one script from another with findweapon, to keep each item's behaviour in its own script.

Files, in docs/examples/shop-and-inventory/:

FileSideRole
lib/items.tsbothshared types (ItemDef, BagRecord, UseContext) and helpers
lib/catalog.tsserverloads the SQLite catalog
weapons/shop.ts / shop.client.tsserver / clientthe shop
weapons/inventory.ts / inventory.client.tsserver / clientthe bag window and item use
weapons/potion.tsserverwhat healing items do
npcs/shopkeeper.npc.ts / .npc.client.tsNPC server / clientthe shopkeeper

Architecture ​

 shopkeeper NPC ──findweapon('shop').trigger('openShop')──▶ shop.ts ──triggerClient 'open'──▶ shop.client.ts
                                                              ▲                                   │
                                                              └──────── triggerServer 'buy' ◀─────┘
                                                              │ writes clientr.gold + bags record
                                                              ▼
 inventory.client.ts ◀── live `bags` mirror (collection replication) ── bags collection
        │ triggerServer 'use'
        ▼
 inventory.ts ──findweapon(item.weapon).trigger('onItemUsed')──▶ potion.ts ──▶ clientr.hp

The server makes every decision: prices, gold, what is in the bag, and what an item does. The clients only display state and send requests.

Step 1: define the bags collection ​

Scripts can read and write collections, but they can't create them. Collections are defined by staff, so a script can't change the storage layout by accident. Open GRC's Collections tool (you need the collections staff right) and create:

SettingValueWhy
Namebagsthe name scripts pass to collection()
Scopeaccountone record set per account
Audienceownereach player receives only their own bag
Backingsqlpersisted in SQLite; idle scopes are unloaded from memory
Replicateeagerthe bag is sent at login and kept in sync

Optionally, give it a schema with name (string, required) and qty (number, required). The server then rejects malformed writes with a clear error.

Each record is keyed by item id, with the value { name, qty }. See Replicated collections for the other scopes and replication modes.

Step 2: shared types in lib/ ​

Both sides need to agree on what an item and a bag record look like. A module in scripts/lib/ can be imported by server scripts, weapons, classes and NPC scripts with a bare specifier: import { … } from 'lib/items'.

ts
// Shared by the serverside AND clientside halves of the shop, inventory and
// potion weapons: `import { ... } from 'lib/items'` works on both sides.
// Keep shared libs to types and pure helpers - each importing script gets
// its own bundled copy, so module-level state here would NOT be shared.

/** One row of the SQLite item catalog. */
export interface ItemDef {
    id: string
    name: string
    description: string
    /** Price in gold for one unit. */
    price: number
    /** Serverside weapon script that runs when the item is used, or null. */
    weapon: string | null
    /** Item-specific strength, e.g. how much a potion heals. */
    power: number
}

/** One record of the `bags` collection, keyed by item id. */
export interface BagRecord {
    /** Display name, copied from the catalog when the item was acquired. */
    name: string
    qty: number
}

/** Handed to an item's onItemUsed(player, item, ctx) handler. */
export interface UseContext {
    /** Set false to keep the unit (e.g. "you're already at full health"). */
    consume: boolean
    /** Reply shown to the player. */
    message: string
}

/** No stack holds more than this many units. */
export const MAX_STACK = 99

export function formatGold(amount: number): string {
    // 12345 -> "12,345 gold"
    return `${String(Math.floor(amount)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')} gold`
}

/** A whole quantity in 1..max, or null for anything else. */
export function parseQty(value: unknown, max: number = MAX_STACK): number | null {
    const n = Number(value)
    return Number.isInteger(n) && n >= 1 && n <= max ? n : null
}

The module is bundled into each script that imports it. Types and pure functions work well here. Module-level state doesn't: shop.ts and inventory.ts would each get their own copy. See Shared lib modules.

Step 3: the catalog in SQLite ​

ts
// SERVER-ONLY lib: loads the item catalog from the SQLite database
// data/databases/catalog.db. Only serverside scripts import it (opendatabase
// doesn't exist on the client). Staff can edit the rows in GRC's SQL
// Explorer; scripts pick the changes up the next time they load.

import type { ItemDef } from 'lib/items'

// Seed rows, inserted only when missing - edits made in the SQL Explorer
// are never overwritten.
const DEFAULT_ITEMS: ItemDef[] = [
    { id: 'apple', name: 'Apple', description: 'Crunchy. Restores 1 health.', price: 3, weapon: 'potion', power: 1 },
    { id: 'potion', name: 'Red Potion', description: 'Restores 4 health.', price: 10, weapon: 'potion', power: 4 },
    { id: 'elixir', name: 'Elixir', description: 'Restores 10 health.', price: 40, weapon: 'potion', power: 10 },
    { id: 'rope', name: 'Rope', description: 'Twenty feet of it. Not much use yet.', price: 5, weapon: null, power: 0 },
]

export async function loadCatalog(): Promise<Map<string, ItemDef>> {
    const db = opendatabase('catalog')
    await db.exec(`CREATE TABLE IF NOT EXISTS items (
        id          TEXT PRIMARY KEY,
        name        TEXT NOT NULL,
        description TEXT NOT NULL DEFAULT '',
        price       INTEGER NOT NULL,
        weapon      TEXT,
        power       INTEGER NOT NULL DEFAULT 0,
        sort        INTEGER NOT NULL DEFAULT 0)`)

    // One statement per exec: ? parameters bind in order.
    for (const [i, item] of DEFAULT_ITEMS.entries()) {
        await db.exec(
            'INSERT OR IGNORE INTO items (id, name, description, price, weapon, power, sort) VALUES (?, ?, ?, ?, ?, ?, ?)',
            [item.id, item.name, item.description, item.price, item.weapon, item.power, i])
    }

    const rows = await db.query(
        'SELECT id, name, description, price, weapon, power FROM items ORDER BY sort, name')
    const catalog = new Map<string, ItemDef>()
    for (const row of rows) {
        const id = String(row.id)
        catalog.set(id, {
            id,
            name: String(row.name),
            description: String(row.description ?? ''),
            price: Math.max(0, Number(row.price) || 0),
            weapon: row.weapon === null ? null : String(row.weapon),
            power: Number(row.power) || 0,
        })
    }
    return catalog
}
  • opendatabase('catalog') opens data/databases/catalog.db and creates it on first use.
  • exec and query return promises. All SQL runs on one server-wide worker thread, so it never blocks the game tick.
  • ? placeholders bind the params array in order. Use one statement per call when you pass parameters.
  • INSERT OR IGNORE seeds the defaults once. After that, the rows are yours to edit in GRC's SQL Explorer, and scripts see the changes the next time they load (save or reload them).
  • Rows come back as SqlRow objects. Convert each column explicitly: NULL arrives as null, and numbers as JS numbers.

lib/catalog.ts is a server-only lib, because opendatabase doesn't exist on the client. That's fine as long as only serverside scripts import it. See SQLite databases.

Step 4: the shop, server side ​

Load once, answer synchronously ​

ts
// The catalog is read once into memory. Every action below must stay
// synchronous: a triggerClient issued after an `await` has no current
// player and would be dropped.
let catalog = new Map<string, ItemDef>()

export function onCreated() {
    loadCatalog()
        .then(loaded => {
            catalog = loaded
            echo(`[shop] catalog loaded: ${catalog.size} item(s)`)
        })
        .catch(e => echo('[shop] catalog failed to load: ' + (e instanceof Error ? e.message : String(e))))
}

Why not query the database whenever someone opens the shop? Because triggerClient only works synchronously inside the handler. After an await, the server no longer knows which player to answer, and the reply is dropped. Keeping the catalog in memory keeps every shop action synchronous.

Opening the shop ​

ts
// Who may buy right now: player id -> expiry time. Only openShop() (called
// by the shopkeeper NPC) grants it, so a modified client can't shop from
// across the map by sending 'buy' directly.
const SESSION_MS = 5 * 60 * 1000
const sessions = new Map<number, number>()

// Exported, so the shopkeeper NPC can call it with
// findweapon('shop')?.trigger('openShop', player, 'General Store').
// trigger() inherits the NPC handler's player context, so triggerClient
// below still reaches the right player.
export function openShop(player: Player, title: string) {
    if (catalog.size === 0) {
        triggerClient('weapon', this.name, 'result', false, 'The shop is still stocking its shelves.')
        return
    }
    sessions.set(player.id, Date.now() + SESSION_MS)
    triggerClient('weapon', this.name, 'open', String(title), [...catalog.values()])
}

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

openShop is an exported function, so another script can call it. The shopkeeper NPC does that in step 7. The sessions map is a simple anti-cheat measure: a player can only buy for a few minutes after a shopkeeper has opened the shop for them. Without it, a modified client could send buy from anywhere on the map.

Buying ​

ts
const bags = collection('bags')

function reply(ok: boolean, text: string) {
    triggerClient('weapon', this.name, 'result', ok, text)
}

export function onActionServerSide(player: Player, action: string, ...params: any[]) {
    if (action === 'buy') buy(player, params[0], params[1])
    else if (action === 'close') sessions.delete(player.id)
}

function buy(player: Player, idParam: unknown, qtyParam: unknown) {
    if ((sessions.get(player.id) ?? 0) < Date.now()) {
        reply(false, 'Talk to a shopkeeper first.')
        return
    }
    const item = catalog.get(String(idParam))
    const qty = parseQty(qtyParam)
    if (!item || qty === null) {
        reply(false, 'That is not for sale.')
        return
    }

    // Collection reads and writes are synchronous against server memory.
    const bag = bags.for(player)
    const owned = (bag.get(item.id) as BagRecord | undefined)?.qty ?? 0
    if (owned + qty > MAX_STACK) {
        reply(false, `You can't carry more than ${MAX_STACK} of those.`)
        return
    }

    const cost = item.price * qty
    const gold = Number(player.clientr.gold) || 0
    if (gold < cost) {
        reply(false, `That costs ${formatGold(cost)}; you have ${formatGold(gold)}.`)
        return
    }

    // Take the gold, then deliver. Both writes replicate to the player's
    // client on their own: the flag immediately, the bag record at the end
    // of this server tick.
    player.clientr.gold = gold - cost
    bag.set(item.id, { name: item.name, qty: owned + qty } satisfies BagRecord)
    reply(true, `Bought ${qty} x ${item.name} for ${formatGold(cost)}.`)
}
  • collection('bags').for(player) selects that account's records. get and set are synchronous against server memory, so the whole purchase happens in one handler call with no await. Changes are delta-synced to the owner's client once per tick and saved to disk in the background.
  • get returns a fresh copy. Modifying it changes nothing until you set (or patch) it.
  • Gold is checked and deducted on the server. The client's idea of the price is never used.

Step 5: the shop window ​

The client builds the window once, in onCreated:

ts
const WIN_W = 520
const WIN_H = 330
const LIST_W = 240
const ROW_H = 28
const TOP = 40

function build() {
    const win = new GuiWindowCtrl('Shop_Window')
    win.extent = `${WIN_W},${WIN_H}`
    win.visible = false

    const list = new GuiScrollCtrl('Shop_List')
    list.position = `12,${TOP}`
    list.extent = `${LIST_W},${WIN_H - TOP - 52}`
    win.addControl(list)

    const detailX = LIST_W + 28
    const name = new GuiTextCtrl('Shop_Name')
    name.position = `${detailX},${TOP}`
    name.fontsize = 18
    name.style = 'b'
    win.addControl(name)

    const desc = new GuiTextCtrl('Shop_Desc')
    desc.position = `${detailX},${TOP + 30}`
    desc.width = WIN_W - detailX - 12   // a width makes the label word-wrap
    desc.fontsize = 14
    win.addControl(desc)

    const qty = new GuiTextEditCtrl('Shop_Qty')
    qty.position = `${detailX},${TOP + 150}`
    qty.extent = '60,28'
    qty.text = '1'
    win.addControl(qty)

    const buy = new GuiButtonCtrl('Shop_Buy')
    buy.text = 'Buy'
    buy.position = `${detailX + 70},${TOP + 150}`
    buy.extent = '90,28'
    win.addControl(buy)

    const gold = new GuiTextCtrl('Shop_Gold')
    gold.position = `12,${WIN_H - 40}`
    gold.fontsize = 14
    gold.color = '255,220,120'
    win.addControl(gold)

    const status = new GuiTextCtrl('Shop_Status')
    status.position = `${detailX},${TOP + 190}`
    status.width = WIN_W - detailX - 12
    status.fontsize = 13
    win.addControl(status)

    const close = new GuiButtonCtrl('Shop_Close')
    close.text = 'Close'
    close.position = `${WIN_W - 102},${WIN_H - 44}`
    close.extent = '90,30'
    win.addControl(close)

    buy.on('click', () => requestBuy())
    close.on('click', () => closeShop())
    // Keyboard events go to the FOCUSED control: once the player clicks into
    // the quantity field, Enter buys. (Letters still reach the field.)
    qty.on('keydown', e => {
        if (e.key === 'enter') requestBuy()
    })

    this.ui = { win, list, name, desc, qty, buy, gold, status }
    this.rows = new Map<string, GuiPanelCtrl>()   // item id -> row
    this.generation = 0
}
  • GuiScrollCtrl is a scrolling viewport. Rows added with addControl can be taller than it.
  • Setting width on a GuiTextCtrl makes it word-wrap.
  • qty.on('keydown', …) is a focus-routed keyboard event. It fires only while the quantity field has focus, so pressing Enter after typing a number buys. Key names are lowercase ('enter'), unlike onKeyPressed.

When the server's open action arrives, the list is rebuilt from the catalog it sent:

ts
export function onCreated() {
    this.items = [] as ItemDef[]
    this.selected = null as string | null
    this.shownGold = -1
    build()
}

export function onActionClientSide(action: string, ...params: any[]) {
    if (action === 'open') {
        openShop(String(params[0] ?? 'Shop'), params[1] as ItemDef[])
    } else if (action === 'result') {
        const status: GuiTextCtrl = this.ui.status
        status.text = String(params[1] ?? '')
        status.color = params[0] ? '150,230,150' : '255,140,140'
    }
}

function openShop(title: string, items: ItemDef[]) {
    this.items = Array.isArray(items) ? items : []
    const { win, list, status } = this.ui

    // Rebuild the rows: one clickable panel with two labels per item.
    // Destroying a panel destroys its children too. Control names are
    // global, so each rebuild uses fresh ones.
    for (const row of this.rows.values()) row.destroy()
    this.rows.clear()
    const prefix = `Shop_${++this.generation}_`
    this.items.forEach((item: ItemDef, i: number) => {
        const row = new GuiPanelCtrl(prefix + 'Row_' + item.id)
        row.position = `0,${i * ROW_H}`
        row.extent = `${LIST_W - 20},${ROW_H - 2}`
        row.borderradius = 4
        list.addControl(row)

        const label = new GuiTextCtrl(prefix + 'Name_' + item.id)
        label.position = '8,4'
        label.fontsize = 14
        label.text = item.name
        row.addControl(label)

        const price = new GuiTextCtrl(prefix + 'Price_' + item.id)
        price.position = `${LIST_W - 90},4`
        price.fontsize = 14
        price.color = '255,220,120'
        price.text = String(item.price)
        row.addControl(price)

        // Clicks on the labels bubble up to the row.
        row.on('click', () => select(item.id))
        this.rows.set(item.id, row)
    })

    win.text = title
    win.position = `${Math.floor((ScreenWidth - WIN_W) / 2)},${Math.floor((ScreenHeight - WIN_H) / 2)}`
    status.text = ''
    win.show()
    win.bringtofront()
    select(this.items[0]?.id ?? null)
}

Clicking a row's label bubbles up to the row's click listener. Control names are global, so each rebuild uses a fresh name prefix instead of reusing the names it just destroyed.

Selection restyles the existing rows in place rather than rebuilding them:

ts
function select(id: string | null) {
    this.selected = id
    const item: ItemDef | undefined = this.items.find((it: ItemDef) => it.id === id)
    const { name, desc, buy } = this.ui
    name.text = item ? item.name : 'Nothing for sale'
    desc.text = item ? `${item.description}\n\n${formatGold(item.price)} each` : ''
    buy.visible = item !== undefined

    // Highlight the selected row by restyling it in place.
    for (const [rowId, row] of this.rows as Map<string, GuiPanelCtrl>)
        row.color = rowId === id ? '80,110,160,200' : ''
}

function requestBuy() {
    const qty = parseQty(this.ui.qty.text)
    if (this.selected === null) return
    if (qty === null) {
        this.ui.status.text = 'Enter a quantity from 1 to 99.'
        this.ui.status.color = '255,140,140'
        return
    }
    triggerServer('weapon', this.name, 'buy', this.selected, qty)
}

function closeShop() {
    this.ui.win.hide()
    triggerServer('weapon', this.name, 'close')
}

And the gold label follows clientr.gold, which the server updates on every purchase:

ts
export function onUpdate() {
    // clientr.gold is written by the server; mirror it while the shop is open.
    const gold = Number(clientr.gold) || 0
    if (this.ui.win.visible && gold !== this.shownGold) {
        this.shownGold = gold
        this.ui.gold.text = `You have ${formatGold(gold)}`
    }
}

export function onKeyPressed(key: string) {
    if (key === 'Escape' && this.ui.win.visible) closeShop()
}

Step 6: the inventory ​

A live mirror on the client ​

ts
const bags = collection('bags')

export function onCreated() {
    this.dirty = true
    this.generation = 0
    this.rows = [] as GuiControl[]
    build()

    // Listeners registered in onCreated belong to this weapon and are
    // removed when it unloads. 'change' = one record changed (null record
    // on delete); 'reset' = the whole replica was replaced (e.g. at login).
    // Only mark dirty here - the rebuild happens once, in onUpdate.
    bags.on('change', () => { this.dirty = true })
    bags.on('reset', () => { this.dirty = true })
}

On the client, collection('bags') is a read-only mirror of your own bag: the server sends it at login and keeps it in sync. The listeners only set a dirty flag, and the rows are rebuilt at most once per frame in onUpdate. Several records can change in one tick, and this way that costs one rebuild instead of several.

ts
function build() {
    const win = new GuiWindowCtrl('Inv_Window')
    win.text = 'Inventory'
    win.extent = `${WIN_W},${WIN_H}`
    win.position = `${ScreenWidth - WIN_W - 20},120`
    win.visible = false

    const list = new GuiScrollCtrl('Inv_List')
    list.position = '12,40'
    list.extent = `${WIN_W - 24},${WIN_H - 96}`
    win.addControl(list)

    const gold = new GuiTextCtrl('Inv_Gold')
    gold.position = `12,${WIN_H - 48}`
    gold.fontsize = 14
    gold.color = '255,220,120'
    win.addControl(gold)

    const status = new GuiTextCtrl('Inv_Status')
    status.position = `12,${WIN_H - 28}`
    status.fontsize = 12
    win.addControl(status)

    this.ui = { win, list, gold, status }
}

function rebuildRows() {
    for (const row of this.rows) row.destroy()
    this.rows = []
    const prefix = `Inv_${++this.generation}_`
    const list: GuiScrollCtrl = this.ui.list

    const keys = bags.keys()   // item ids, sorted
    keys.forEach((id, i) => {
        const record = bags.get(id) as BagRecord
        const row = new GuiPanelCtrl(prefix + id)
        row.position = `0,${i * ROW_H}`
        row.extent = `${WIN_W - 44},${ROW_H - 4}`
        row.color = '255,255,255,20'
        row.borderradius = 4
        list.addControl(row)

        const label = new GuiTextCtrl(prefix + id + '_Label')
        label.position = '8,5'
        label.fontsize = 14
        label.text = `${record.name}  x${record.qty}`
        row.addControl(label)

        const use = new GuiButtonCtrl(prefix + id + '_Use')
        use.text = 'Use'
        use.position = `${WIN_W - 112},2`
        use.extent = '60,24'
        use.on('click', () => triggerServer('weapon', this.name, 'use', id))
        row.addControl(use)

        this.rows.push(row)
    })
    if (keys.length === 0) {
        const empty = new GuiTextCtrl(prefix + 'Empty')
        empty.text = 'Your bag is empty. Visit a shop!'
        empty.fontsize = 14
        list.addControl(empty)
        this.rows.push(empty)
    }
}
ts
export function onUpdate() {
    const win: GuiWindowCtrl = this.ui.win
    if (!win.visible) return
    if (this.dirty) {
        this.dirty = false
        rebuildRows()
    }
    const gold = Number(clientr.gold) || 0
    if (gold !== this.shownGold) {
        this.shownGold = gold
        this.ui.gold.text = formatGold(gold)
    }
}

export function onKeyPressed(key: string) {
    if (key !== 'I') return
    const win: GuiWindowCtrl = this.ui.win
    if (win.visible) {
        win.hide()
    } else {
        this.dirty = true
        this.ui.status.text = ''
        win.show()
        win.bringtofront()
    }
}

export function onActionClientSide(action: string, ok: boolean, text: string) {
    if (action !== 'result') return
    const status: GuiTextCtrl = this.ui.status
    status.text = String(text ?? '')
    status.color = ok ? '150,230,150' : '255,140,140'
}

Using items with findweapon ​

The Use button sends triggerServer('weapon', 'inventory', 'use', id). On the server:

ts
function useItem(player: Player, id: string) {
    const bag = bags.for(player)
    const record = bag.get(id) as BagRecord | undefined
    if (!record || record.qty < 1) {
        reply(false, "You don't have that.")
        return
    }

    const item = catalog.get(id)
    if (!item?.weapon) {
        reply(false, `You can't use the ${record.name}.`)
        return
    }

    // findweapon returns the LOADED serverside half of scripts/weapons/<name>.ts.
    // trigger() runs its handler right now, passing live values: the item
    // script gets the real Player and can edit ctx in place.
    const ctx: UseContext = { consume: true, message: `You use the ${item.name}.` }
    const handler = findweapon(item.weapon)
    if (!handler || !handler.trigger('onItemUsed', player, item, ctx)) {
        reply(false, 'Nothing happens.')
        return
    }

    if (ctx.consume) {
        if (record.qty > 1)
            bag.patch(id, { qty: record.qty - 1 })   // merge just this field
        else
            bag.delete(id)
    }
    reply(true, ctx.message)
}

The inventory weapon doesn't know what a potion does. The catalog row names a weapon script (weapon = 'potion'), and findweapon returns a handle on that script's loaded serverside half. trigger calls its exported handler immediately, with live values: the real Player object and the same ctx object, which the item script can modify. It returns false if the script is gone or the handler threw, and the inventory then keeps the item.

The item script itself:

ts
// Item script for every healing item in the catalog (apple, potion, elixir:
// their `weapon` column is 'potion'). It is a serverside-only weapon script:
// nobody is ever granted it; the inventory weapon calls it with
// findweapon('potion')?.trigger('onItemUsed', player, item, ctx).

import type { ItemDef, UseContext } from 'lib/items'

export function onItemUsed(player: Player, item: ItemDef, ctx: UseContext) {
    const maxhp = Number(player.clientr.maxhp) || 10
    const hp = Number(player.clientr.hp) || 0
    if (hp >= maxhp) {
        ctx.consume = false          // keep the item
        ctx.message = 'You are already at full health.'
        return
    }

    const healed = Math.min(maxhp - hp, item.power)
    player.clientr.hp = hp + healed
    player.chat = `*uses ${item.name}*`
    ctx.message = `The ${item.name} restores ${healed} health.`
}

potion.ts has no clientside half and is never granted to anyone. It's a serverside script in weapons/ that other scripts call. To add a new kind of item, add a catalog row and one small script. inventory.ts never changes.

bag.patch(id, { qty }) merges one field into a record. Unlike a get-then-set, it can't overwrite a change made in between.

Step 7: the shopkeeper NPC ​

NPC scripts live in the level, not in scripts/. Open the level in GRC's level editor, add an NPC where you want the shop, and paste the two scripts into its serverside and clientside script tabs. Saving the level reloads its NPCs.

Serverside: opening the shop ​

ts
export function onCreated() {
    this.showCharacter()
    this.head = 'head3.png'
    this.body = 'body2.png'
    this.dir = 2                       // facing down
    this.chat = 'Say "shop", or press A next to me!'
    // triggerAction and projectiles hit-test the shape, in pixels from the
    // NPC's top-left. A character has no image, so give it one: 2x2 tiles.
    this.setShape(0, 0, 32, 32)
}

In an NPC script, this is the NPC itself (NpcThis). Assigning head, body, dir or chat updates the NPC for everyone in the level. showCharacter() draws it as a gani character. Characters have no image, so setShape gives it a 32×32-pixel hitbox for triggerAction to hit.

ts
// Both ways in end here. Handlers run with the player as the "current
// player", and findweapon(...).trigger inherits that context, so the shop
// weapon's triggerClient reaches the right client.
function openShopFor(player: Player) {
    if (Math.abs(player.x - this.x) > 4 || Math.abs(player.y - this.y) > 4)
        return   // too far away to trade
    if (!findweapon('shop')?.trigger('openShop', player, 'General Store'))
        echo('[shopkeeper] the shop weapon is not loaded')
    this.chat = `Welcome, ${player.nick}!`
}

// Typed chat from anyone in the level.
export function onPlayerChats(player: Player, chat: string) {
    if (chat.trim().toLowerCase() === 'shop')
        openShopFor(player)
}

// The clientside script's triggerAction(x, y, 'trade') lands here, with
// the triggering player first.
export function onActionTrade(player: Player) {
    openShopFor(player)
}

There are two ways in:

  1. Say "shop". A serverside NPC's onPlayerChats(player, chat) fires for chat typed by anyone in the level. The NPC checks distance itself.
  2. Press A (handled by the clientside script below), which ends up in onActionTrade.

Both call findweapon('shop')?.trigger('openShop', player, …). NPC handlers such as onPlayerChats and onAction… run with that player as the current player, and trigger inherits that context, so the shop's triggerClient reaches the right client.

Clientside: a hint and a key ​

ts
// Clientside script of the shopkeeper NPC (paste it into the NPC's
// clientside script in GRC's level editor). It runs on every client in the
// level; `this` is this client's copy of the NPC, and `player` is the
// LOCAL player.

const HINT_IMG = 1   // findimg ids are per script: no clash with weapons

function playerIsNear(): boolean {
    return Math.abs(player.x - this.x) <= 3 && Math.abs(player.y - this.y) <= 3
}

// A floating "[A] Trade" hint while the local player stands close.
export function onUpdate() {
    const near = playerIsNear()
    if (near === this.hintShown) return
    this.hintShown = near
    if (!near) {
        hideimg(HINT_IMG)
        return
    }
    const hint = findimg(HINT_IMG)
    hint.text = '[A] Trade'
    hint.style = 'bc'
    hint.fontsize = 12
    hint.textshadow = true
    hint.x = this.x + 1          // world mode: tile coordinates
    hint.y = this.y - 2.2
}

export function onKeyPressed(key: string) {
    // Ask the server to fire onActionTrade on whatever NPC shape covers
    // this point - our own middle. The server hit-tests its own shapes.
    if (key === 'A' && playerIsNear())
        triggerAction(this.x + 1, this.y + 1, 'trade')
}

The clientside script runs on every client in the level. player is always the local player, so each client checks its own distance and shows the hint only to players who are close. findimg ids are private to this script, so id 1 here can't collide with a weapon's id 1.

triggerAction(x, y, 'trade') asks the server to fire onActionTrade(player) on every NPC whose shape contains the point. The action name is capitalized to build the handler name. The server hit-tests its own copy of the shapes, which is why the serverside setShape matters. See NPCs.

Complete files ​

Every file from this tutorial, in full, for copying into GRC.

npcs/shopkeeper.npc.ts
ts
// Serverside script of the shopkeeper NPC. In a real server this is NOT a
// file: paste it into the NPC's serverside script in GRC's level editor
// (the .npc.ts suffix only exists so these docs can type-check it).
// `this` is the NPC; property writes replicate to everyone in the level.

export function onCreated() {
    this.showCharacter()
    this.head = 'head3.png'
    this.body = 'body2.png'
    this.dir = 2                       // facing down
    this.chat = 'Say "shop", or press A next to me!'
    // triggerAction and projectiles hit-test the shape, in pixels from the
    // NPC's top-left. A character has no image, so give it one: 2x2 tiles.
    this.setShape(0, 0, 32, 32)
}

// Both ways in end here. Handlers run with the player as the "current
// player", and findweapon(...).trigger inherits that context, so the shop
// weapon's triggerClient reaches the right client.
function openShopFor(player: Player) {
    if (Math.abs(player.x - this.x) > 4 || Math.abs(player.y - this.y) > 4)
        return   // too far away to trade
    if (!findweapon('shop')?.trigger('openShop', player, 'General Store'))
        echo('[shopkeeper] the shop weapon is not loaded')
    this.chat = `Welcome, ${player.nick}!`
}

// Typed chat from anyone in the level.
export function onPlayerChats(player: Player, chat: string) {
    if (chat.trim().toLowerCase() === 'shop')
        openShopFor(player)
}

// The clientside script's triggerAction(x, y, 'trade') lands here, with
// the triggering player first.
export function onActionTrade(player: Player) {
    openShopFor(player)
}
weapons/inventory.client.ts
ts
// Clientside half of the `inventory` weapon: press I to toggle a window
// listing the `bags` collection. The list is a live read-only mirror of
// the server's records - it updates by itself after every purchase or use.

import { formatGold, type BagRecord } from 'lib/items'

const WIN_W = 360
const WIN_H = 320
const ROW_H = 32

const bags = collection('bags')

export function onCreated() {
    this.dirty = true
    this.generation = 0
    this.rows = [] as GuiControl[]
    build()

    // Listeners registered in onCreated belong to this weapon and are
    // removed when it unloads. 'change' = one record changed (null record
    // on delete); 'reset' = the whole replica was replaced (e.g. at login).
    // Only mark dirty here - the rebuild happens once, in onUpdate.
    bags.on('change', () => { this.dirty = true })
    bags.on('reset', () => { this.dirty = true })
}

function build() {
    const win = new GuiWindowCtrl('Inv_Window')
    win.text = 'Inventory'
    win.extent = `${WIN_W},${WIN_H}`
    win.position = `${ScreenWidth - WIN_W - 20},120`
    win.visible = false

    const list = new GuiScrollCtrl('Inv_List')
    list.position = '12,40'
    list.extent = `${WIN_W - 24},${WIN_H - 96}`
    win.addControl(list)

    const gold = new GuiTextCtrl('Inv_Gold')
    gold.position = `12,${WIN_H - 48}`
    gold.fontsize = 14
    gold.color = '255,220,120'
    win.addControl(gold)

    const status = new GuiTextCtrl('Inv_Status')
    status.position = `12,${WIN_H - 28}`
    status.fontsize = 12
    win.addControl(status)

    this.ui = { win, list, gold, status }
}

function rebuildRows() {
    for (const row of this.rows) row.destroy()
    this.rows = []
    const prefix = `Inv_${++this.generation}_`
    const list: GuiScrollCtrl = this.ui.list

    const keys = bags.keys()   // item ids, sorted
    keys.forEach((id, i) => {
        const record = bags.get(id) as BagRecord
        const row = new GuiPanelCtrl(prefix + id)
        row.position = `0,${i * ROW_H}`
        row.extent = `${WIN_W - 44},${ROW_H - 4}`
        row.color = '255,255,255,20'
        row.borderradius = 4
        list.addControl(row)

        const label = new GuiTextCtrl(prefix + id + '_Label')
        label.position = '8,5'
        label.fontsize = 14
        label.text = `${record.name}  x${record.qty}`
        row.addControl(label)

        const use = new GuiButtonCtrl(prefix + id + '_Use')
        use.text = 'Use'
        use.position = `${WIN_W - 112},2`
        use.extent = '60,24'
        use.on('click', () => triggerServer('weapon', this.name, 'use', id))
        row.addControl(use)

        this.rows.push(row)
    })
    if (keys.length === 0) {
        const empty = new GuiTextCtrl(prefix + 'Empty')
        empty.text = 'Your bag is empty. Visit a shop!'
        empty.fontsize = 14
        list.addControl(empty)
        this.rows.push(empty)
    }
}

export function onUpdate() {
    const win: GuiWindowCtrl = this.ui.win
    if (!win.visible) return
    if (this.dirty) {
        this.dirty = false
        rebuildRows()
    }
    const gold = Number(clientr.gold) || 0
    if (gold !== this.shownGold) {
        this.shownGold = gold
        this.ui.gold.text = formatGold(gold)
    }
}

export function onKeyPressed(key: string) {
    if (key !== 'I') return
    const win: GuiWindowCtrl = this.ui.win
    if (win.visible) {
        win.hide()
    } else {
        this.dirty = true
        this.ui.status.text = ''
        win.show()
        win.bringtofront()
    }
}

export function onActionClientSide(action: string, ok: boolean, text: string) {
    if (action !== 'result') return
    const status: GuiTextCtrl = this.ui.status
    status.text = String(text ?? '')
    status.color = ok ? '150,230,150' : '255,140,140'
}
weapons/inventory.ts
ts
// Serverside half of the `inventory` weapon: seeds starting gold and uses
// items. What an item DOES lives in the weapon script its catalog row names
// (the `weapon` column) - this script finds it with findweapon() and calls
// its onItemUsed handler.

import { loadCatalog } from 'lib/catalog'
import type { BagRecord, ItemDef, UseContext } from 'lib/items'

const START_GOLD = 50
const bags = collection('bags')

// Each script that imports lib/catalog loads its own copy: lib module
// state is never shared between scripts.
let catalog = new Map<string, ItemDef>()

export function onCreated() {
    loadCatalog()
        .then(loaded => { catalog = loaded })
        .catch(e => echo('[inventory] catalog failed to load: ' + (e instanceof Error ? e.message : String(e))))
}

export function onPlayerJoined(player: Player) {
    if (typeof player.clientr.gold !== 'number')
        player.clientr.gold = START_GOLD
}

function reply(ok: boolean, text: string) {
    triggerClient('weapon', this.name, 'result', ok, text)
}

export function onActionServerSide(player: Player, action: string, ...params: any[]) {
    if (action === 'use') useItem(player, String(params[0] ?? ''))
}

function useItem(player: Player, id: string) {
    const bag = bags.for(player)
    const record = bag.get(id) as BagRecord | undefined
    if (!record || record.qty < 1) {
        reply(false, "You don't have that.")
        return
    }

    const item = catalog.get(id)
    if (!item?.weapon) {
        reply(false, `You can't use the ${record.name}.`)
        return
    }

    // findweapon returns the LOADED serverside half of scripts/weapons/<name>.ts.
    // trigger() runs its handler right now, passing live values: the item
    // script gets the real Player and can edit ctx in place.
    const ctx: UseContext = { consume: true, message: `You use the ${item.name}.` }
    const handler = findweapon(item.weapon)
    if (!handler || !handler.trigger('onItemUsed', player, item, ctx)) {
        reply(false, 'Nothing happens.')
        return
    }

    if (ctx.consume) {
        if (record.qty > 1)
            bag.patch(id, { qty: record.qty - 1 })   // merge just this field
        else
            bag.delete(id)
    }
    reply(true, ctx.message)
}
weapons/shop.client.ts
ts
// Clientside half of the `shop` weapon: a window listing the catalog the
// server sent, a detail pane for the selected item, a quantity field and a
// Buy button. It never decides anything - every purchase is a request.

import { formatGold, parseQty, type ItemDef } from 'lib/items'

const WIN_W = 520
const WIN_H = 330
const LIST_W = 240
const ROW_H = 28
const TOP = 40

function build() {
    const win = new GuiWindowCtrl('Shop_Window')
    win.extent = `${WIN_W},${WIN_H}`
    win.visible = false

    const list = new GuiScrollCtrl('Shop_List')
    list.position = `12,${TOP}`
    list.extent = `${LIST_W},${WIN_H - TOP - 52}`
    win.addControl(list)

    const detailX = LIST_W + 28
    const name = new GuiTextCtrl('Shop_Name')
    name.position = `${detailX},${TOP}`
    name.fontsize = 18
    name.style = 'b'
    win.addControl(name)

    const desc = new GuiTextCtrl('Shop_Desc')
    desc.position = `${detailX},${TOP + 30}`
    desc.width = WIN_W - detailX - 12   // a width makes the label word-wrap
    desc.fontsize = 14
    win.addControl(desc)

    const qty = new GuiTextEditCtrl('Shop_Qty')
    qty.position = `${detailX},${TOP + 150}`
    qty.extent = '60,28'
    qty.text = '1'
    win.addControl(qty)

    const buy = new GuiButtonCtrl('Shop_Buy')
    buy.text = 'Buy'
    buy.position = `${detailX + 70},${TOP + 150}`
    buy.extent = '90,28'
    win.addControl(buy)

    const gold = new GuiTextCtrl('Shop_Gold')
    gold.position = `12,${WIN_H - 40}`
    gold.fontsize = 14
    gold.color = '255,220,120'
    win.addControl(gold)

    const status = new GuiTextCtrl('Shop_Status')
    status.position = `${detailX},${TOP + 190}`
    status.width = WIN_W - detailX - 12
    status.fontsize = 13
    win.addControl(status)

    const close = new GuiButtonCtrl('Shop_Close')
    close.text = 'Close'
    close.position = `${WIN_W - 102},${WIN_H - 44}`
    close.extent = '90,30'
    win.addControl(close)

    buy.on('click', () => requestBuy())
    close.on('click', () => closeShop())
    // Keyboard events go to the FOCUSED control: once the player clicks into
    // the quantity field, Enter buys. (Letters still reach the field.)
    qty.on('keydown', e => {
        if (e.key === 'enter') requestBuy()
    })

    this.ui = { win, list, name, desc, qty, buy, gold, status }
    this.rows = new Map<string, GuiPanelCtrl>()   // item id -> row
    this.generation = 0
}

export function onCreated() {
    this.items = [] as ItemDef[]
    this.selected = null as string | null
    this.shownGold = -1
    build()
}

export function onActionClientSide(action: string, ...params: any[]) {
    if (action === 'open') {
        openShop(String(params[0] ?? 'Shop'), params[1] as ItemDef[])
    } else if (action === 'result') {
        const status: GuiTextCtrl = this.ui.status
        status.text = String(params[1] ?? '')
        status.color = params[0] ? '150,230,150' : '255,140,140'
    }
}

function openShop(title: string, items: ItemDef[]) {
    this.items = Array.isArray(items) ? items : []
    const { win, list, status } = this.ui

    // Rebuild the rows: one clickable panel with two labels per item.
    // Destroying a panel destroys its children too. Control names are
    // global, so each rebuild uses fresh ones.
    for (const row of this.rows.values()) row.destroy()
    this.rows.clear()
    const prefix = `Shop_${++this.generation}_`
    this.items.forEach((item: ItemDef, i: number) => {
        const row = new GuiPanelCtrl(prefix + 'Row_' + item.id)
        row.position = `0,${i * ROW_H}`
        row.extent = `${LIST_W - 20},${ROW_H - 2}`
        row.borderradius = 4
        list.addControl(row)

        const label = new GuiTextCtrl(prefix + 'Name_' + item.id)
        label.position = '8,4'
        label.fontsize = 14
        label.text = item.name
        row.addControl(label)

        const price = new GuiTextCtrl(prefix + 'Price_' + item.id)
        price.position = `${LIST_W - 90},4`
        price.fontsize = 14
        price.color = '255,220,120'
        price.text = String(item.price)
        row.addControl(price)

        // Clicks on the labels bubble up to the row.
        row.on('click', () => select(item.id))
        this.rows.set(item.id, row)
    })

    win.text = title
    win.position = `${Math.floor((ScreenWidth - WIN_W) / 2)},${Math.floor((ScreenHeight - WIN_H) / 2)}`
    status.text = ''
    win.show()
    win.bringtofront()
    select(this.items[0]?.id ?? null)
}

function select(id: string | null) {
    this.selected = id
    const item: ItemDef | undefined = this.items.find((it: ItemDef) => it.id === id)
    const { name, desc, buy } = this.ui
    name.text = item ? item.name : 'Nothing for sale'
    desc.text = item ? `${item.description}\n\n${formatGold(item.price)} each` : ''
    buy.visible = item !== undefined

    // Highlight the selected row by restyling it in place.
    for (const [rowId, row] of this.rows as Map<string, GuiPanelCtrl>)
        row.color = rowId === id ? '80,110,160,200' : ''
}

function requestBuy() {
    const qty = parseQty(this.ui.qty.text)
    if (this.selected === null) return
    if (qty === null) {
        this.ui.status.text = 'Enter a quantity from 1 to 99.'
        this.ui.status.color = '255,140,140'
        return
    }
    triggerServer('weapon', this.name, 'buy', this.selected, qty)
}

function closeShop() {
    this.ui.win.hide()
    triggerServer('weapon', this.name, 'close')
}

export function onUpdate() {
    // clientr.gold is written by the server; mirror it while the shop is open.
    const gold = Number(clientr.gold) || 0
    if (this.ui.win.visible && gold !== this.shownGold) {
        this.shownGold = gold
        this.ui.gold.text = `You have ${formatGold(gold)}`
    }
}

export function onKeyPressed(key: string) {
    if (key === 'Escape' && this.ui.win.visible) closeShop()
}
weapons/shop.ts
ts
// Serverside half of the `shop` weapon. It owns the purchase rules: the
// catalog (from SQLite), the prices, the player's gold (clientr.gold) and
// their bag (the `bags` collection). The clientside half only displays.

import { loadCatalog } from 'lib/catalog'
import { formatGold, parseQty, MAX_STACK, type BagRecord, type ItemDef } from 'lib/items'

// The catalog is read once into memory. Every action below must stay
// synchronous: a triggerClient issued after an `await` has no current
// player and would be dropped.
let catalog = new Map<string, ItemDef>()

export function onCreated() {
    loadCatalog()
        .then(loaded => {
            catalog = loaded
            echo(`[shop] catalog loaded: ${catalog.size} item(s)`)
        })
        .catch(e => echo('[shop] catalog failed to load: ' + (e instanceof Error ? e.message : String(e))))
}

// Who may buy right now: player id -> expiry time. Only openShop() (called
// by the shopkeeper NPC) grants it, so a modified client can't shop from
// across the map by sending 'buy' directly.
const SESSION_MS = 5 * 60 * 1000
const sessions = new Map<number, number>()

// Exported, so the shopkeeper NPC can call it with
// findweapon('shop')?.trigger('openShop', player, 'General Store').
// trigger() inherits the NPC handler's player context, so triggerClient
// below still reaches the right player.
export function openShop(player: Player, title: string) {
    if (catalog.size === 0) {
        triggerClient('weapon', this.name, 'result', false, 'The shop is still stocking its shelves.')
        return
    }
    sessions.set(player.id, Date.now() + SESSION_MS)
    triggerClient('weapon', this.name, 'open', String(title), [...catalog.values()])
}

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

const bags = collection('bags')

function reply(ok: boolean, text: string) {
    triggerClient('weapon', this.name, 'result', ok, text)
}

export function onActionServerSide(player: Player, action: string, ...params: any[]) {
    if (action === 'buy') buy(player, params[0], params[1])
    else if (action === 'close') sessions.delete(player.id)
}

function buy(player: Player, idParam: unknown, qtyParam: unknown) {
    if ((sessions.get(player.id) ?? 0) < Date.now()) {
        reply(false, 'Talk to a shopkeeper first.')
        return
    }
    const item = catalog.get(String(idParam))
    const qty = parseQty(qtyParam)
    if (!item || qty === null) {
        reply(false, 'That is not for sale.')
        return
    }

    // Collection reads and writes are synchronous against server memory.
    const bag = bags.for(player)
    const owned = (bag.get(item.id) as BagRecord | undefined)?.qty ?? 0
    if (owned + qty > MAX_STACK) {
        reply(false, `You can't carry more than ${MAX_STACK} of those.`)
        return
    }

    const cost = item.price * qty
    const gold = Number(player.clientr.gold) || 0
    if (gold < cost) {
        reply(false, `That costs ${formatGold(cost)}; you have ${formatGold(gold)}.`)
        return
    }

    // Take the gold, then deliver. Both writes replicate to the player's
    // client on their own: the flag immediately, the bag record at the end
    // of this server tick.
    player.clientr.gold = gold - cost
    bag.set(item.id, { name: item.name, qty: owned + qty } satisfies BagRecord)
    reply(true, `Bought ${qty} x ${item.name} for ${formatGold(cost)}.`)
}

Try it ​

  1. Create the bags collection (step 1).
  2. Create the lib/ files in the Lib editor first, then the weapons in the Weapons editor, and grant shop and inventory to yourself from GRC's Players window (right-click, Grant weapon…). For health to heal, also install the hud weapon from tutorial 2. It provides clientr.hp/maxhp and shows them.
  3. Place the shopkeeper NPC in your start level and save it.
  4. The server log in GRC should show [shop] catalog loaded: 4 item(s).
  5. Walk up to the shopkeeper and press A (or say shop). Select Red Potion, click into the quantity field, type 3 and press Enter. Your gold drops.
  6. Press I: three potions. Press H a few times to lose some health, then click Use. The HUD bar fills back up and the potion count drops. Use one at full health and you keep it.
  7. Try to buy 1000 elixirs: the server refuses, and your gold is unchanged.
  8. Open the SQL Explorer, change the price of potion in catalog.db, then save (or reload) shop.ts. The shop shows the new price.

Next steps ​

  • Selling: add a sell action that removes units and refunds, for example, half the catalog price.
  • Item icons: add an icon column and show it with a GuiImageCtrl in each row. Restyle rows in place instead of recreating image controls, which can flicker while their texture loads.
  • Several shops: add a shop_items(shop_id, item_id, price) table and pass the shop id from each shopkeeper NPC to openShop.
  • Catalog as a collection: move the catalog into a read-only, global collection with replicate: cache. Clients then fetch() definitions on demand instead of receiving them with every open.
  • Stale names: bag records copy the item name at purchase time. Write a staff command that re-syncs names after the catalog changes, or look names up from the catalog on the client.