Appearance
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 module | Class | |
|---|---|---|
| Shared how | Inlined into each importing script at build time | Joined at runtime with this.join() |
| Contains | Functions, constants, types | Event handlers + helpers that run as the joiner |
this | Not meant to use it | The joining NPC/weapon |
| Module-level state | A separate copy per importing script | One copy shared by every joiner |
| Types across scripts | Yes — real import type | No — 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 scripts | lib/side.ts |
| Clientside weapons, class client halves, clientside NPC scripts | lib/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.