Skip to content

Shared lib modules ​

scripts/lib/ holds ordinary TypeScript modules that any script can import: helper functions, constants, and — just as important — types shared between the server and client halves of a system.

ts
// scripts/lib/items.ts
export interface Item {
    id: string
    name: string
    price: number
}

export function formatPrice(n: number): string {
    return `${n} gralats`
}
ts
// scripts/weapons/shop.ts (serverside) and shop.client.ts (clientside) alike
import { formatPrice, type Item } from 'lib/items'

The import specifier is always the bare lib/<name> (no ./, no .ts), from any folder — weapons, plain scripts, classes, level NPC scripts. Libs can import other libs the same way. Edit them in GRC's Lib window.

When to use a lib (vs a class) ​

Lib moduleClass
Shared howInlined into each importing script at build timeJoined at runtime with this.join()
ContainsFunctions, constants, typesEvent handlers + helpers that run as the joiner
thisNot meant to use itThe joining NPC/weapon
Module-level stateA separate copy per importing scriptOne copy shared by every joiner
Types across scriptsYes — real import typeNo — class members are any

Rule of thumb: stateless helpers and shared types go in lib/; shared behaviour (handlers) goes in a class.

How it works: bundling ​

Every script is bundled with esbuild before it runs: the lib code a script imports is copied into that script's bundle. What ships to the client is still one self-contained file per weapon, class or NPC, and nothing about lib modules exists at runtime.

Two consequences follow:

Module state is not shared

Because each importer gets its own copy, a module-level variable in a lib is per script:

ts
// lib/counter.ts
let hits = 0
export function hit() { return ++hits }

Weapon A and weapon B calling hit() each count separately. For state shared across scripts use server flags, a collection, a database or a class's module-level state.

Edits propagate automatically

Saving a lib rebuilds the scripts that import it (weapons, plain scripts and classes), and they hot-reload like any other change — see NPCs for the exception.

Side-specific halves: lib/<name>.client.ts ​

A lib can have a clientside twin. The same specifier resolves per side:

Compiling…import … from 'lib/side' resolves to
Serverside scripts, weapon server halves, class server halves, serverside NPC scriptslib/side.ts
Clientside weapons, class client halves, clientside NPC scriptslib/side.client.ts if it exists, else lib/side.ts

For example, this pair:

ts
// lib/side.ts
export function whichSide(): string { return 'server' }
ts
// lib/side.client.ts
export function whichSide(): string { return 'client' }

An inventory system might be organised like this: lib/itemkeys.ts holds the pure, shared record types and helpers; lib/inventory.ts is the server API (reads and writes collections) and lib/inventory.client.ts the client read API. Both halves export * from 'lib/itemkeys', so callers on either side simply write import { getItemDef, itemLabel } from 'lib/inventory'.

A lib with only a .client.ts file (e.g. a GUI helper like lib/toast.client.ts) can only be imported from clientside code; a serverside import fails to resolve.

Shared libs must stay side-neutral

A plain lib/x.ts without a client twin is compiled into both server and client scripts, so it must not touch side-specific globals (player means different things on each side; findimg doesn't exist on the server; collection() has different APIs). Keep such files to pure logic and types, and move anything that needs globals into a .client.ts / server pair.

Type checking ​

Lib files are not type-checked on their own: they are checked through the scripts that import them, against that script's globals. A lib nobody imports gets no diagnostics, and a shared lib is checked twice — once as server code, once as client code — which is exactly what catches accidental use of a one-side global.

GRC's editors resolve lib/… imports the same way, following the side of the tab you're editing (a .client.ts tab resolves client halves first).

NPC scripts ​

Level NPC scripts (and putnpc source strings) can import libs exactly like other scripts:

ts
// Serverside half of a level NPC
import { formatPrice } from 'lib/items'

export function onActionTalk(player: Player) {
    this.chat = `Potions: ${formatPrice(35)}`
}

NPCs pick up lib edits on the next level reload

NPC scripts are compiled when their level is activated or re-saved. After editing a lib, NPCs already running keep the old copy until their level is saved again in GRC's Level Editor. Weapons, plain scripts and classes are rebuilt immediately.

Only lib/* ​

Only lib/<name> imports are supported: you can't install npm packages on your server, and lib/ modules are what the editors, type checking and hot reload are set up for.

See also ​