World-Builder Guide
NPC Scripting Guide
Write scriptable NPCs — dialogue, shops, quests, memory. Every hook and host function.
Reference for writing scriptable NPCs in the Fragmented Planes engine. Covers every hook, host function, return shape, and memory constraint.
1. Architecture — The Trust Boundary
┌─────────────────────────────────────────────────────────────┐
│ SCRIPT (Lua) PROPOSES intents │
│ • talk() → { msg, options?, give?, quest?, open_shop? } │
│ • show() → items[] │
│ • buy() → { item_def_id, price } | nil │
│ • sell() → { price } | nil │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SERVER (Go) DISPOSES — validates + applies │
│ • Checks wallet, inventory, stock before mutating │
│ • Atomically resolves give/buy/sell │
│ • Never trusts the script to write state directly │
└─────────────────────────────────────────────────────────────┘
Golden rule: Scripts propose what should happen. The server validates and applies. A script cannot:
- Directly modify wallet, inventory, or stock
- Read other players' memory
- Access filesystem, network, wall-clock
- Call
require,dofile,loadstring - Use
math.random(userng()instead)
2. Script File Structure
Every script lives in scripts/<script_id>.lua in the bundle. It must:
- Declare
API_VERSION(1or2) at top level - Define at minimum a
talk()function - Optionally define
show(),buy(),sell()(merchants), andmove()/close()(lifecycle)
-- Minimal valid script
API_VERSION = 1
function talk(input, p)
return { msg = "Hello." }
end
Required Top-Level Global
API_VERSION = 2 -- the scripting API contract (use 1 if you don't call self_id())
- Must be a number literal at top level
- Server range:
[1, 2] - v1 —
talk/show/buy/sell+ themove()/close()lifecycle hooks - v2 — adds
self_id()(§6) for per-instance state across multiple spawns of one script - Declare the highest version whose features you use; existing v1 scripts keep working unchanged
- Missing or out-of-range → bundle rejected at load
3. Hook: talk(input, playerInfo)
The dialogue driver. Called on every interaction with the NPC.
Signature
function talk(input, p) → TalkResult | nil
Parameters
| Param | Type | Description |
|---|---|---|
input | string or nil | nil on first interact; the chosen option.id on subsequent calls |
p | table | Read-only player snapshot (see §4) |
Return: TalkResult
{
msg = string, -- required: dialogue text
options? = { { id = string, text = string } }, -- nil/empty = end dialogue
give? = Give, -- nil = no grant
quest? = { -- nil = no quest display
id = string,
title = string,
objective = string,
status = string, -- "offered" | "active" | "complete"
reward? = string, -- display text
},
open_shop? = boolean, -- true = client opens shop panel
}
msgis the only required fieldoptionswith zero/absent entries → dialogue ends (no choice)open_shopis a client hint only — triggersshow()call
Give sub-table
{
items? = { { item_def_id = string, count = number } }, -- items granted
gold? = number, -- gold credited
take? = { { item_def_id = string, count = number } }, -- items consumed
}
All fields optional. take items are verified present before anything is applied (atomic).
Example: Re-entrant talk
function talk(input, p)
local mem = get_memory(p.id)
if input == nil then
return {
msg = "Hello " .. p.name .. "!",
options = {
{ id = "story", text = "Tell me more" },
{ id = "bye", text = "Goodbye" },
},
}
end
if input == "story" then
return { msg = "Once upon a time...",
options = { { id = "root", text = "..." } } }
end
return { msg = "Farewell." } -- no options → ends dialogue
end
4. Player Info Table (p)
Read-only snapshot of the interacting player. Passed to every hook.
{
id = string, -- stable player ID
name = string, -- display name
wallet = number, -- current gold balance
inventory = { -- array of item stacks
{ item_def_id = string, count = number },
...
},
}
Helper: Check inventory count
local function inv_count(p, item_def_id)
for _, slot in ipairs(p.inventory) do
if slot.item_def_id == item_def_id then return slot.count end
end
return 0
end
5. Memory System
Per-NPC scratch storage. Two scopes: per-player and global.
get_memory(player_id?) → table
-- Per-player: each player has their own blob for THIS NPC
local mem = get_memory(p.id)
-- Global: shared across ALL players talking to THIS NPC
local global = get_memory()
- Without arg → global blob
- With
p.id→ that player's private blob - Cannot pass another player's ID (raises Lua error)
- Returns
{}(empty table) if no memory exists yet
save_memory(blob, player_id?)
-- Save per-player
local mem = get_memory(p.id)
mem.visited = true
save_memory(mem, p.id)
-- Save global
local global = get_memory()
global.total_interactions = (global.total_interactions or 0) + 1
save_memory(global)
Constraints
| Constraint | Limit |
|---|---|
| Max blob size (JSON) | 16 KiB |
| Scope | Only current player's blob + global |
| Persistence | Durable — the area server's embedded SQLite (area.db, npc_memory table) when run with a data-dir; in-memory/ephemeral only if none is set (e.g. tests) |
| Key proliferation | Bound to { "", playerID } per NPC |
Memory Patterns
-- One-time welcome
if not mem.welcomed then
mem.welcomed = true
save_memory(mem, p.id)
-- give welcome gift
end
-- Cooldown gate (using now())
local DAY = 86400
if now() - (mem.last_gift or 0) < DAY then
return { msg = "Come back tomorrow." }
end
mem.last_gift = now()
save_memory(mem, p.id)
-- Quest state machine
if mem.quest == "complete" then
-- done
elseif mem.quest == "active" then
-- check progress
else
-- offer quest
end
Multiple spawns of one script
Memory is keyed per script, not per spawn — so every NPC that uses tree.lua shares the same global blob, and talk() alone can't tell which spawn it's running for. To give each spawn independent state, namespace the blob on self_id() (§6):
local g = get_memory()
local me = g[self_id()] or {} -- just THIS spawn's slice
6. Host Functions
Available globally inside any script hook.
now() → number
Server game-time (monotonic tick). Constant within a single hook call.
local DAY = 86400 -- one game-day in now() units
local HOUR = 3600
-- Cooldown: "come back later"
if now() - (mem.last_gift or 0) < DAY then
return { msg = "Not yet." }
end
-- Time-gated dialogue
if now() > 50000 then
-- late-game dialogue
end
rng() → number
Seeded per-call deterministic random [0, 1). Same seed → same sequence.
-- Random greeting
local greetings = { "Hey.", "Ho.", "Hah!" }
local msg = greetings[math.floor(rng() * #greetings) + 1]
log(msg)
Server-side logging (rate-limited). Useful for debugging.
log("player " .. p.name .. " entered gift state")
base_stock() → table[]
Returns the NPC's server-authoritative merchant stock. Read-only.
-- Each element:
{ item_def_id = string, price = number, stock = number }
-- stock == -1 means infinite
-- stock == 0 means sold out
Used by show() and buy()/sell() to filter/reprice.
function show(p)
local out = {}
for _, e in ipairs(base_stock()) do
if e.stock ~= 0 then
out[#out + 1] = { item_def_id = e.item_def_id, price = e.price, stock = e.stock }
end
end
return out
end
self_id() → string (API v2)
The spawn-instance id of the NPC this hook is running for. Available in every hook (talk, move, close, show, buy, sell). Two spawns that share one script get different self_id() values, so one script can back many independent objects (a field of crop plots, a row of trees) by namespacing memory on it:
-- per-instance global state (shared by all players, unique per spawn)
local g = get_memory()
local me = g[self_id()] or {}
me.count = (me.count or 0) + 1
g[self_id()] = me
save_memory(g)
-- per-instance AND per-player (e.g. a daily cooldown for THIS plot only)
local pm = get_memory(p.id)
pm[self_id()] = now()
save_memory(pm, p.id)
Memory is still stored per script, so all spawns share one blob (now sub-keyed by
self_id()) and the 16 KiB cap covers every instance combined. Ideal for a handful of instances; not for thousands of heavy-state spawns of one script.
7. Shop Hooks
For merchant NPCs. All optional — if absent, the shop UI won't open.
show(p) → ShopItem[]
Called when open_shop = true from talk() or when client opens shop panel.
function show(p)
local out = {}
for _, e in ipairs(base_stock()) do
if e.stock ~= 0 then
out[#out + 1] = {
item_def_id = e.item_def_id,
price = math.max(1, math.floor(e.price * 0.8)), -- 20% discount
stock = e.stock,
}
end
end
return out
end
buy(item_def_id, p) → BuyOffer | nil
Called when player clicks "Buy". Return nil to refuse.
function buy(item_def_id, p)
for _, e in ipairs(base_stock()) do
if e.item_def_id == item_def_id and e.stock ~= 0 then
return { item_def_id = item_def_id, price = e.price }
end
end
return nil -- not sold here
end
Server enforces: item exists in registry → stock available → wallet ≥ price.
sell(item_def_id, p) → SellOffer | nil
Called when player clicks "Sell". Return nil to refuse.
function sell(item_def_id, p)
for _, e in ipairs(base_stock()) do
if e.item_def_id == item_def_id then
return { price = math.max(1, math.floor(e.price * 0.5)) }
end
end
return nil -- won't buy it
end
Server enforces: player owns the item → remove it → credit wallet.
8. Lifecycle Hooks — move() and close()
Two optional hooks let an NPC act on its own clock and clean up after a dialogue. Both work under any API version (no bump needed).
move(w) → MoveAction | nil
Called every tick the NPC is due, for any NPC whose script defines move(). Lets it step around the map and/or change its world sprite. It runs world-scoped — with no player, so get_memory() (global) works but get_memory(p.id) does not. Return nil/{} to idle.
World table w:
{
pos = { x = number, y = number }, -- the NPC's current tile
home = { x = number, y = number }, -- where it spawned (anchor for "home")
map = { id = string, w = number, h = number },
walkable = { up = bool, down = bool, left = bool, right = bool }, -- adjacent tile free?
players = { { x = number, y = number, dist = number }, ... }, -- dist = Chebyshev
vision = { -- present ONLY if the def sets vision_radius > 0
radius = number,
tiles = { { bool, ... }, ... }, -- row-major walkable grid, centered on the NPC
},
}
Return: MoveAction
{
move = string, -- "up"|"down"|"left"|"right"|"wander"|"chase"|"flee"|"home"; omit = stay put
sprite = string, -- swap the NPC's world sprite (shared: broadcast to everyone)
face = string, -- "up"|"down"|"left"|"right" — face a direction without moving
say = string, -- reserved
sfx = string, -- reserved
effect = string, -- reserved
}
- The server disposes: it resolves the verb to one validated tile on the real grid
(walls and other NPCs block it) and applies sprite via an entity_sprite broadcast — the sprite is shared world state, identical for every viewer. Setting sprite to the current one is a no-op (no broadcast).
chase/fleetarget the nearest player inplayers;homewalks back towardhome;
wander takes a random free step.
Cadence & scale:
- Speed-gated by the entity def's
move_speed(higher = more often). A **player-presence
gate** skips NPCs on maps with no connected player — they catch up lazily from now() when someone arrives, so idle NPCs cost nothing.
- Each
move()runs under a tight (~10 ms) deadline; one slow script costs one worker one
tick, isolated from the rest.
- One
LStateper script means all spawns of a script serialize — useself_id()(§6) to
give each spawn its own state.
-- A tree that shows its stage; never walks. self_id() keeps each spawn independent.
function move(w)
local g = get_memory()
local me = g[self_id()] or {}
return { sprite = stage_sprite(me) } -- no `move` verb = stays rooted
end
close(p) → (ignored)
Fires when a player leaves a dialogue with this NPC (walks away or presses Leave). Use it to release per-player state — a "busy" lock, a conversation step. Fire-and-forget: the return value is ignored and nothing is sent to the client.
function close(p)
local g = get_memory()
if g.talking then
g.talking[p.id] = nil
save_memory(g)
end
end
This is the only explicit "dialogue ended" signal. A
talk()result with nooptionsalso ends the dialogue, but onlyclose()catches the player leaving.
9. Quest Display
The quest field in talk() return is purely display metadata. No server-side quest engine exists.
quest = {
id = "father_reveal", -- unique quest ID
title = "The Truth", -- quest journal title
objective = "Find Grix at Abandûm", -- current objective text
status = "active", -- "offered" | "active" | "complete"
reward = "Knowledge", -- display text
}
The client accumulates these into its journal. Quest state lives in per-player memory.
10. Sandbox — What's Available
| Allowed | Removed |
|---|---|
string lib (upper, lower, rep, concat, sub, match, etc.) | os, io, debug |
table lib (insert, remove, concat, sort, ipairs, etc.) | require, module |
math lib (floor, ceil, max, min, abs, etc.) | math.random, math.randomseed |
tostring, tonumber, type, ipairs, pairs | dofile, loadfile, load, loadstring |
# (length operator), .. (concat) | print, collectgarbage |
Host: now(), rng(), log(), get_memory(), save_memory(), base_stock(), self_id() | coroutine |
11. Best Practices
Memory first, talk second
function talk(input, p)
local mem = get_memory(p.id) -- ← always first line
if input == nil then
-- root menu, use mem for state
end
-- always save after mutation
save_memory(mem, p.id)
end
One-time flags
if not mem.did_thing then
mem.did_thing = true
save_memory(mem, p.id)
-- give the thing
end
Dialogue IDs by convention
| ID | Meaning |
|---|---|
nil / "" | First interaction / restart |
"root" | Back to main menu |
"bye" | End dialogue |
"accept" | Accept quest |
"turnin" | Turn in quest |
Any string | Your custom node ID |
Ending the dialogue
-- No options = conversation ends
return { msg = "Goodbye." }
Always provide an escape
Every menu should have a way back to root or a bye:
options = {
{ id = "shop", text = "Show wares" },
{ id = "root", text = "Back" }, -- ← always
}
Save after mutation, before return
mem.something = value
save_memory(mem, p.id) -- ← save first
return { msg = "Done." }
12. Complete Reference — Return Shapes
talk() return
{
-- Required
msg = string,
-- Optional (all fields optional)
options = {
{ id = string, text = string },
...
},
give = {
items = { { item_def_id = string, count = number }, ... },
gold = number,
take = { { item_def_id = string, count = number }, ... },
},
quest = {
id = string,
title = string,
objective = string,
status = string, -- "offered" | "active" | "complete"
reward = string,
},
open_shop = boolean,
}
show() return
{
{ item_def_id = string, price = number, stock = number },
...
}
buy() return
{ item_def_id = string, price = number }
-- or nil to refuse
sell() return
{ price = number }
-- or nil to refuse
13. Quick Template
API_VERSION = 1
local function inv_count(p, item_def_id)
for _, slot in ipairs(p.inventory) do
if slot.item_def_id == item_def_id then return slot.count end
end
return 0
end
function talk(input, p)
local mem = get_memory(p.id)
if input == nil or input == "root" then
return {
msg = "Hello, " .. p.name .. ".",
options = {
{ id = "root", text = "..." },
},
}
end
return { msg = "Farewell." }
end
function show(p)
local out = {}
for _, e in ipairs(base_stock()) do
if e.stock ~= 0 then
out[#out + 1] = { item_def_id = e.item_def_id, price = e.price, stock = e.stock }
end
end
return out
end
function buy(item_def_id, p)
for _, e in ipairs(base_stock()) do
if e.item_def_id == item_def_id and e.stock ~= 0 then
return { item_def_id = item_def_id, price = e.price }
end
end
return nil
end
function sell(item_def_id, p)
for _, e in ipairs(base_stock()) do
if e.item_def_id == item_def_id then
return { price = math.max(1, math.floor(e.price * 0.5)) }
end
end
return nil
end