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:


2. Script File Structure

Every script lives in scripts/<script_id>.lua in the bundle. It must:

  1. Declare API_VERSION (1 or 2) at top level
  2. Define at minimum a talk() function
  3. Optionally define show(), buy(), sell() (merchants), and move()/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())

3. Hook: talk(input, playerInfo)

The dialogue driver. Called on every interaction with the NPC.

Signature

function talk(input, p)  →  TalkResult | nil

Parameters

ParamTypeDescription
inputstring or nilnil on first interact; the chosen option.id on subsequent calls
ptableRead-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
}

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()

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

ConstraintLimit
Max blob size (JSON)16 KiB
ScopeOnly current player's blob + global
PersistenceDurable — 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 proliferationBound 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
}

(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).

wander takes a random free step.

Cadence & scale:

gate** skips NPCs on maps with no connected player — they catch up lazily from now() when someone arrives, so idle NPCs cost nothing.

tick, isolated from the rest.

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 no options also ends the dialogue, but only close() 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

AllowedRemoved
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, pairsdofile, 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

IDMeaning
nil / ""First interaction / restart
"root"Back to main menu
"bye"End dialogue
"accept"Accept quest
"turnin"Turn in quest
Any stringYour 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