Appearance
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
collectionand 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, andtriggerAction. - Calling one script from another with
findweapon, to keep each item's behaviour in its own script.
Files, in docs/examples/shop-and-inventory/:
| File | Side | Role |
|---|---|---|
lib/items.ts | both | shared types (ItemDef, BagRecord, UseContext) and helpers |
lib/catalog.ts | server | loads the SQLite catalog |
weapons/shop.ts / shop.client.ts | server / client | the shop |
weapons/inventory.ts / inventory.client.ts | server / client | the bag window and item use |
weapons/potion.ts | server | what healing items do |
npcs/shopkeeper.npc.ts / .npc.client.ts | NPC server / client | the 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.hpThe 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:
| Setting | Value | Why |
|---|---|---|
| Name | bags | the name scripts pass to collection() |
| Scope | account | one record set per account |
| Audience | owner | each player receives only their own bag |
| Backing | sql | persisted in SQLite; idle scopes are unloaded from memory |
| Replicate | eager | the 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')opensdata/databases/catalog.dband creates it on first use.execandqueryreturn promises. All SQL runs on one server-wide worker thread, so it never blocks the game tick.?placeholders bind theparamsarray in order. Use one statement per call when you pass parameters.INSERT OR IGNOREseeds 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
SqlRowobjects. Convert each column explicitly:NULLarrives asnull, 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.getandsetare synchronous against server memory, so the whole purchase happens in one handler call with noawait. Changes are delta-synced to the owner's client once per tick and saved to disk in the background.getreturns a fresh copy. Modifying it changes nothing until youset(orpatch) 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
}GuiScrollCtrlis a scrolling viewport. Rows added withaddControlcan be taller than it.- Setting
widthon aGuiTextCtrlmakes 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'), unlikeonKeyPressed.
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:
- Say "shop". A serverside NPC's
onPlayerChats(player, chat)fires for chat typed by anyone in the level. The NPC checks distance itself. - 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
- Create the
bagscollection (step 1). - Create the
lib/files in the Lib editor first, then the weapons in the Weapons editor, and grantshopandinventoryto yourself from GRC's Players window (right-click, Grant weapon…). For health to heal, also install thehudweapon from tutorial 2. It providesclientr.hp/maxhpand shows them. - Place the shopkeeper NPC in your start level and save it.
- The server log in GRC should show
[shop] catalog loaded: 4 item(s). - Walk up to the shopkeeper and press A (or say
shop). Select Red Potion, click into the quantity field, type3and press Enter. Your gold drops. - 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.
- Try to buy 1000 elixirs: the server refuses, and your gold is unchanged.
- Open the SQL Explorer, change the price of
potionincatalog.db, then save (or reload)shop.ts. The shop shows the new price.
Next steps
- Selling: add a
sellaction that removes units and refunds, for example, half the catalog price. - Item icons: add an
iconcolumn and show it with aGuiImageCtrlin 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 toopenShop. - Catalog as a collection: move the catalog into a read-only, global collection with
replicate: cache. Clients thenfetch()definitions on demand instead of receiving them with everyopen. - 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.