Building
Add behaviour, saved data and your own model to placeable structures
What you can do after this page
- Give a workbench, chest or machine in your game behaviour of your own
- Run your code the moment a structure is placed, used or destroyed
- Store numbers on each structure and find them again the next time you play
- Put your own 3D model and colour on a structure standing in the world
- Make a structure do work on a timer while you play
Defining a building
A building is anything you pick from the build menu and set down in the world: workbenches, chests, machines, decorations. Once one is standing there, PalForge gives it a live object of its own — its own position, its own saved data, and the handlers you wrote.
local api = require("palforge.api") -- also installs the bare globals
local Building = api.BuildingThere are three things you do with Building:
local bench = Building{ id = "example:Bench", name = "Modded Bench" } -- define
local box = Building.get("PalBoxV2") -- look one up
local all = Building.get_all() -- every registered oneThe only required field is id.
Building{ id = "WorkBench" }That one call is what puts the id on PalForge's watch list. The scan that finds placed
structures in the world takes its definitions from the registry and from nowhere else, so it
tracks exactly the build ids a define call registered. Building.get(id) on an id nobody
defined still hands back a handle you can call methods on, but it registers nothing, so
:instances() on it stays empty.
A full definition reads as one table:
local bench = Building{
id = "example:Bench",
name = "Modded Bench",
description = "A bench that counts how often it is used.",
gridCm = 100,
tickInterval = 4,
mesh = {
kind = "static",
model = "/Game/Pal/Model/Prop/Architecture/WorkBenchPrimitive/SM_WorkBenchPrimitive.SM_WorkBenchPrimitive",
},
color = { r = 0.8, g = 0.3, b = 0.1, a = 1.0 },
state = function() return { uses = 0 } end,
events = {
onPlace = function(self, ctx) self:save() end,
onRightClick = function(self, ctx)
self.state.uses = self.state.uses + 1
self:save()
end,
},
data = { tier = 2 },
}The mesh — the 3D model the placed structure wears — can be written inline, or reused from a
named Mesh{ ... } definition. Both go through the same checks.
Building{
id = "example:Bench",
mesh = {
kind = "static",
model = "/Game/Pal/Model/Prop/Architecture/WorkBenchPrimitive/SM_WorkBenchPrimitive.SM_WorkBenchPrimitive",
},
}An inline table that leaves out kind gets "static", the Building.Spec.Mesh default.
Ids
An id without a colon is a literal game BuildObjectId — "WorkBench", "PalBoxV2",
"ItemChest". An id written as "pack:name" becomes the row name pack_name in the game's
own data tables. That resolved name is what :unlock() unlocks, and what the runtime matches
a placed structure against.
Defining does not add a new entry to the build menu. Lua alone cannot add a row to the game's data tables — that is PalSchema's job. What a definition does is give an id that already exists behaviour, saved data, a model and metadata.
The shape of an id is checked when you define it, not when something later goes looking for the structure. A colon anywhere means you meant the namespaced form, so both halves must be letters, digits and underscores:
Building{ id = "my-pack:Bench" }
-- PalForge: Building: field "id" is invalid: invalid pack id 'my-pack' in 'my-pack:Bench' (letters/digits/_ only)A hyphen makes the id unresolvable, and an unresolvable id has nowhere to go: the DataTable row name cannot be built, so the icon lookup, the technology unlock and the build-id match all miss while the definition sits in the registry looking healthy. The define call is the only place you can watch that happen, so that is where it stops.
buildIds gets the same question one step later, at the engine boundary, and more forgivingly:
an entry that will not resolve is used literally and a warning names the id and the reason
resolve gave. A literal that matches no real BuildObjectId never matches an actor, which is a
silent miss you can chase; a dropped id would be a definition that registered, answered every
read, and was dead.
Defining the same id twice replaces the earlier one. native.buildings registers nothing, so a
pack that defines WorkBench is the only definition of it — but a second pack that defines the
same id replaces yours, and the registry logs the collision naming both owners.
The native catalog
native/buildings.lua carries all 498 DT_BuildObjectDataTable_Common row ids as plain data,
plus two hand-written definitions that add a mesh and a display name.
local buildings = require("palforge.native.buildings")
buildings.WorkBench -- curated handle for "WorkBench" (BP_BuildObject_WorkBench_C)
buildings.PalBox -- curated handle for "PalBoxV2"
buildings.get("ItemChest") -- a handle, built on first read and cached; nil for an unknown id
buildings.publish("WorkBench") -- opt IN to tracking, and with it to persistence
buildings.CATALOG -- the 498 ids, as dataNothing in that module registers anything, and reading a field registers nothing either. That
matters more here than in any other domain, because registering a building is not inert: the
scan picks a registered definition up, every matching actor already standing in the world becomes
a tracked instance, and a tracked instance is written into the save's entity file. A tooltip that
read buildings.Stone_Foundation would otherwise start persisting every stone foundation in the
base, and a picker walking CATALOG would persist the whole base.
So a handle from get(id) or from a named field answers :unlock() and :iconOf() for real —
both are id-driven table reads — while :instances() stays empty and nothing is written, until
you call buildings.publish(id) or declare Building{ id = <the same id> } yourself.
Spec fields
Everything you can pass to Building{ ... }. Most buildings need only id, then state and
events once you want them to do something.
Prop
Type
Every problem is a hard error, so a call never half-succeeds. A misspelled field tells you the name it expected:
Building{ id = "example:Bench", grid = 100 }PalForge: Building: unknown field "grid" (did you mean "gridCm"?). Valid fields: id, name, description, gridCm, buildIds, tickInterval, mesh, material, color, texture, icon, state, events, dataPalForge: Building: field "id" is required (build id: a game BuildObjectId ("PalBoxV2") or "pack:name")
PalForge: Building: field "mesh" (Building.Spec.Mesh): field "model" is required (UStaticMesh asset path, or an OBJ path for the procedural backend)Rather than memorise the list, print it while the game runs:
local schema = require("palforge.core.schema")
print(schema.help("Building.Spec"))
print(schema.help("Building.Spec.Events"))
schema.get("Building.Spec").fields -- the same, as a table, for toolingThe second argument
Building(spec, opts) takes an optional second table. It controls registration and nothing
else — the definition it builds is the same either way:
Building(spec) -- define and register
Building(spec, { register = false }) -- build the handle, register nothing
Building(spec, { pack = "mypack" }) -- register with that pack recorded as the ownerregister = false is the difference between a read and a write here, for the reason above: a
registered definition is tracked, and a tracked structure is persisted. The handle still works,
core/event never sees the definition, and nothing goes into the save.
pack is what gives a collision a "who": the owner is recorded on the registration, named in the
warning when two packs claim one id, and written into every persisted record.
PalForge.pack("mypack").Building fills it in for you.
state — a table or a factory
state is the data a new structure starts with. PalForge writes it to disk for you and
hands it back the next time you play. It is read once: when the scan first creates a structure
for which no saved record exists.
state = { uses = 0 } -- a table
state = function() return { uses = 0 } end -- a factory, called per new instanceThe factory is called with the definition class as its argument, so
state = function(cls) ... end works too. If it throws, or returns something that is not a
table, the structure starts with an empty table.
Prefer the factory. A plain table is stored on the definition and handed to every new structure by reference — two structures placed from the same definition then share one state table, and both saved records point at it. The factory builds a fresh table per structure.
State is saved as JSON, so keep it to strings, numbers, true/false and nested tables of those. A restored structure gets exactly the record that was saved — the default state is not merged in — so a field you add in a later version needs a guard:
onLoad = function(self, ctx)
self.state.uses = self.state.uses or 0
self.state.tier = self.state.tier or 1 -- added in a later version of the pack
end,gridCm — how a structure is recognised again
A placed structure carries no stable id of its own, so PalForge identifies one by rounding its
world position to a grid cell. core/spatial rounds each axis with
math.floor(v / gridCm + 0.5) and writes the result as buildId@qx,qy,qz:
WorkBench@1234,-56,78gridCm defaults to core.spatial.GRID_CM, which is 100 — one metre. That key names the
saved record, so it is what reattaches saved data to a structure found again in your next
session. A smaller cell separates structures that stand close together; a larger one tolerates
more drift in the reported position between sessions. Two structures whose positions land in
the same cell collide on the key: the scan keeps the structure bound to the first valid actor
and skips the second.
While you are playing, a structure is tracked by its actor — the object the game put in the world — not by that key. A placed structure's reported location moves by more than a cell between scans, so tracking by actor is what stops one structure turning into an endless stream of new ones.
buildIds — claiming more than one game id
buildIds is the list of game build ids this definition answers for. It replaces the
default { id }, so include the id itself if you still want it matched:
Building{
id = "example:Chests",
name = "Instrumented Chests",
buildIds = { "ItemChest", "ItemChest_02", "ItemChest_03" },
events = {
onRightClick = function(self, ctx)
log.info("opened " .. self.buildId) -- the id this instance matched
end,
},
}Each entry goes through object_manager.resolve (so "pack:name" becomes pack_name) and is
indexed to this definition. A placed structure records the one it matched in self.buildId,
while self.id stays the definition id.
Matching is tried three ways, in order: the actor's class name BP_BuildObject_<Id>_C, then
MapObjectModel.BuildObjectId, then a position match against a saved record. The first way
works only if the id is spelled the way the blueprint spells it — that is why the ready-made
definition uses "WorkBench" while the data table row is spelled Workbench.
mesh and material
Building.Spec.Mesh is the shape described on Mesh, with one change: kind
defaults to "static" rather than "skeletal". material, color and texture layer over
whatever the mesh declares: Class:material() returns the declared material table, or builds
one from color / texture when you used the shorthands, and Class:render() lets the
material win field by field.
Two of the three backends behind kind attach for real on a placed structure. static — the
default here — adds a UStaticMeshComponent, finds model through LoadAsset with a
StaticFindObject fallback, sets the asset, and then reads it back off the component before
reporting success, so a game build where the setter is missing gets an honest false and no
orphaned component left behind. procedural (also spelled obj) reads a Wavefront OBJ file
from disk at model and adds a ProceduralMeshComponent. Both turn collision off — a decorative
collider would block the raycast the game uses to place the structure — and both set the world
scale explicitly, because the empty transform handed to AddComponentByClass starts it at
zero.
-- a UE-authored asset, the Building.Spec.Mesh default kind
mesh = {
kind = "static",
model = "/Game/Pal/Model/Other/PalBox/SM_PalBox.SM_PalBox",
scale = 1.0,
offset = { x = 0, y = 0, z = 50 },
},
-- an OBJ file, parsed from disk when the mesh attaches
mesh = {
kind = "procedural",
model = "C:/mods/example/models/bench.obj",
scale = 1.0,
offset = { x = 0, y = 0, z = 50 },
},That absolute OBJ path is correct on exactly one machine. Mesh.Spec resolves a relative
model or texture against the calling pack's own directory, but that resolution lives on
Mesh.Spec:validate and Building.Spec.Mesh is a derived spec with its own — so a relative
path in an inline building mesh stays relative and fails at io.open with what you wrote.
Declare the OBJ as a named Mesh{ ... } and nest that handle, which is the route that carries
the resolved path.
The curated WorkBench and PalBoxV2 declare static meshes. They only reach a placed actor
once something registers them — buildings.publish(id), or your own definition — and then they
attach on the scan after the one that first sees the actor.
skeletal swaps the asset on the pawn's own Mesh component — ACharacter::Mesh, which a
build object does not have, so a structure declaring kind = "skeletal" attaches nothing, logs
that the actor is probably not an APalCharacter, and its render() returns false. That
backend is also unconfirmed in game: it goes through the live signature check and reads the asset
back to compare it, so a setter that runs and is ignored is a false rather than a pretended
success — but nobody has watched anything visibly change shape.
The live instance
Nothing you define is alive until a real structure stands in the world. Here is what happens when you place one.
The model appears one scan after the structure does, not the instant it is placed. Adding a component to an actor that is still setting itself up — the frame it is placed — can touch an invalid game object and crash the game, so the structure is marked pending and gets its model on the next scan that sees the same actor again.
How a scan decides what it is looking at:
building.place is emitted only when a fresh structure is matched to a recorded placement
request for the same build id within 300 cm. Every structure the scan newly tracks then emits
building.load — including the one that just emitted building.place — with
ctx.reconstructed telling you whether it came from a saved record. A structure is created
once per key, so onPlace fires exactly once per placement.
Instance fields
Prop
Type
Anything the definition declared is reachable through the class, so self.name, self.data,
self.gridCm and the rest resolve on the placed structure too.
Instance methods
self:save() -- stage state and write the json file now
self:setDirty() -- stage only; written by the next save or on world.left
self:isValid() -- is self.actor still a valid engine object
self:render() -- attach mesh + material once; false without a valid actor or a model
self:update() -- re-tint the live material from self:currentColor()
self:neighbors(cm) -- every OTHER tracked structure within cm of this one
self:mesh() -- the declared mesh table
self:material() -- the declared material, or one built from color / texture
self:currentColor() -- the tint update() will write; override for a state-driven look
self:iconOf() -- DT_BuildObjectIconDataTable lookup, falling back to the declared iconcurrentColor() returns self.color, which is the definition's tint by default. Set the field
on the structure itself to make the look follow the state, then push it — or override the method
on the definition class when the colour is a function of the state:
onTick = function(self, ctx)
self.color = self.state.running
and { r = 0.2, g = 0.9, b = 0.3, a = 1.0 }
or { r = 0.4, g = 0.4, b = 0.4, a = 1.0 }
self:update()
end,update() goes through core/mesh, which remembers which backend dressed each actor — keyed on
the actor's GetFullName(), because a UE4SS handle is minted fresh per lookup — and sends the
re-tint back to that same backend. static, procedural and skeletal all answer: the shared
material layer makes the dynamic material instance on the spot when the mesh was attached without
a colour. true means a write executed on a real material instance. It is not proof of a
visible change: a write to a parameter name the Palworld material does not carry is a silent
no-op, and no tint has been watched land in game. false means there was nothing to write to —
no mesh of ours on the actor, or no colour.
neighbors
self:neighbors(radiusCm) is every other tracked structure within that radius — any
definition, not just this one's — as live instances. It is core/spatial's hash grid, and the
call re-buckets the tracked instances first, so a structure that moved is still found.
onTick = function(self, ctx)
for _, n in ipairs(self:neighbors(350)) do -- everything within 3.5 m
log.info(self.key .. " is next to " .. n.key)
end
end,It returns an empty list on a definition rather than an instance (there is no self.pos there)
and on a radius that is not a positive number. Only structures PalForge is tracking are in the
index, so a base full of unregistered vanilla ids answers empty.
Saving state
Records live in one JSON file per mod, inside one directory per save:
<UE4SS Mods>/PalForge/state/w_1DF0E44B4FDDD6196E30819A899C9009/mypack.jsonThe directory name comes from core/spatial, which reads the selected save off the live
PalGameInstance. It tries GetSelectedWorldSaveDirectoryName first and falls back to
GetSelectedWorldName, each with its backing property (SelectedWorldSaveDirectoryName,
SelectedWorldName) as a second chance; the answer becomes w_ plus that name with anything
that is not a letter, digit or underscore replaced by _, and plain world when nothing can be
read. The file name is the pack id you passed to PalForge.pack, so a structure of yours is
never in a file another mod also writes.
WorldGuid, WorldSaveName and SaveName are not on that class. PalGameInstance's full
111-property listing carries neither, so a probe built on those three names could only ever land
on the world fallback — which is why the one artifact an early session left behind is called
entities_world.json. The directory name is preferred over the display name because two saves
cannot share a save folder, while two saves may well carry the same label the player typed.
The file holds one record per structure key, plus a quarantine section:
{
"palforge": { "format": 3, "mod": "mypack", "save": "w_1DF0E44B4FDDD6196E30819A899C9009",
"forge": "0.3.0", "wrote": 1785646408, "buildings": 1 },
"buildings": {
"WorkBench@1234,-56,78": {
"buildId": "WorkBench",
"def": "mypack:Bench",
"pos": [123400, -5600, 7800],
"state": { "uses": 3 }
}
},
"orphans": {}
}The key is the resolved build id and a cell, so it names no owner at all. The owner is the
file name and the record shape is the header's format, so neither is repeated per record.
def is the definition id that claimed it, and it is what lets one narrow rename case migrate
itself. Positions are integer centimetres. Saved state covers the
layout, the store your pack gets on top of it, and what happens to all of this when the mod is
uninstalled.
A record's state is the same table as inst.state, so changing it in place is enough to
change what will be written. Nothing writes on its own:
self:setDirty()marks this structure's mod dirty. Cheap.self:save()marks it dirty and writes that one mod's file straight away, returningtrue, orfalseand a reason.- Leaving the world writes everything still dirty, then drops the live structures and keeps every record.
Call save() when the change matters and setDirty() in a hot loop, otherwise a save() on
every tick writes the file on every heartbeat. A write only ever covers the mod that changed:
another mod's file is not rewritten because yours was.
No record is deleted for being absent. A structure the scan fails to see for six ENUMERATING
scans in a row emits building.remove with
reason = "missing" and its record is quarantined, not dropped: it moves into orphans with
why = "missing", and the next scan that sees that actor again takes it back out with its
state intact. That scan enumerates in-memory objects only, and the shipping binary's own
declarations say Palworld spawns and disposes map objects by proximity, so "absent from this
scan" cannot be read as "gone".
A record whose build id no definition claims this session is quarantined the same way, with
why = "unclaimed": moved into orphans with its bytes intact, logged with a count and with the
packs it belonged to, and moved back the moment a definition claims that build id again. That
pass waits about 30 seconds after a world opens, so a pack that defines its buildings at
world.ready or lazily has time to get there. The only thing in the runtime that ever destroys a
record is the quarantine cap of 4096 per mod file, past which that mod's oldest quarantined
records are dropped oldest-first, and the log says how many, for which mod, and why.
Quarantined and Dropped both keep the saved record, so the structure comes back with its data
when it or its definition returns. Nothing in that diagram deletes one.
Reaching the instances
local event = require("palforge.core.event")
Building.get("example:Kiln"):instances() -- every live instance of one definition
event.instances() -- every live instance, any definition
event.instances("example:Kiln") -- filtered by definition id or matched build id
event.instanceOfActor(ctx.actor) -- the instance bound to an actor, or nil
event.isWorldReady() -- has the world finished loading:instances() is empty until the scan has run, and the scan needs a loaded world: a watch
polls for a valid PalPlayerCharacter once a second and only opens the gate after five
successful polls in a row.
All of it survives a hot reload. The building runtime holds its definition index, its live
instances and its actor index in one table on _G, the store keeps the merged record view in
another, and core/reload keeps object_manager across the wipe, so pressing F9 leaves
:instances() answering and the building hooks firing on the definitions you already had. The two halves have to stay together:
a reload that dropped the registry would look to the orphan pass like every pack unloading at
once, about thirty seconds after the press.
Lifecycle events
Handlers go under events. The first argument is the placed structure the event happened to,
so self.state, self.pos and self.actor are right there. onBuild is the one exception,
because it fires before anything is standing in the world.
events = {
onRightClick = function(self, ctx) -- self is a Building.Instance
log.info(self.key .. " at " .. tostring(self.pos.x))
end,
}| Event | Channel | Fires | ctx |
|---|---|---|---|
onPlace | building.place | LIVE | key, actor, pos, buildId, player, firstSeen |
onLoad | building.load | LIVE | key, actor, pos, buildId, reconstructed |
onRightClick | building.interact | LIVE | actor, player, buildId |
onRemove | building.remove | LIVE | key, buildId, actor, reason |
onTick | tick | LIVE | count, now |
onBuild | building.build | LIVE, once the world has loaded | buildId, model |
onWorldReady | world.ready | LIVE | empty table |
onWorldLeft | world.left | LIVE | empty table |
onLeftClick | — | never | — |
onBreak | — | never | — |
onLeftClick and onBreak can be declared, but nothing emits them, so they never run. That is
settled negatively rather than not yet found — see What is not there below. Use
onRightClick for interaction and onRemove for a structure that disappears.
What is not there, and how that was established
Both missing hooks were searched for by reading the complete function lists of every class that could own one, so this is a measurement rather than a gap.
There is no click, hit or strike entry on PalBuildObject. Its 22 reflected functions are on
disk in full. The only input-shaped entries are the interact family —
OnBeginInteractBuilding, OnTriggerInteractBuilding, OnStartTriggerInteractBuilding,
OnEndTriggerInteractBuilding — which is right-click, and is already onRightClick. The one
damage-shaped entry, OnDamage, was the standing candidate and is not a strike: a recorded
session caught it firing 196 times on a strict 12-13 second per-structure cadence, with no
player anywhere near it. The workbench placed at t=306.412 took its first at t=306.933 and 180
more over the next 2250 seconds without dying. It is the deterioration timer, and wiring
onLeftClick to it would call your handler every twelve seconds on every structure in the base,
for ever.
Destruction exists only as delegate FIELDS. None of PalBuildObject (22 functions),
PalMapObjectModel (18), PalMapObjectConcreteModelBase (25) or PalNetworkPlayerComponent
(77) carries a Destroy / Dismantle / Demolish / Deconstruct / Break function. What does exist is
PalMapObjectModel:OnDestroyDelegate and :OnDisposeDelegateInServer, and RegisterHook
cannot address a delegate field by path. PalBuildObject.OnChangeVisualForDismantle is the
dismantle preview visual, not a completion.
So a structure disappearing surfaces one way: the scan's miss sweep, as onRemove with
ctx.reason = "missing". That cannot tell a dismantle from a structure that streamed out, and it
arrives six scans late. Both hooks stay declarable so your own emit works and so a future
source has somewhere to arrive; nothing emits them today. The one place the dumps do not reach is
a BP_BuildObject_<Id>_C subclass graph event — they cover /Script/Pal.* only.
onPlace
Fires once, on the scan that discovers the new actor and matches it to a placement request
recorded by the RequestBuild_ToServer hook. ctx.player is the PalPlayerCharacter the hook
saw when it recorded the request, ctx.firstSeen is always true, and ctx.pos is the
actor's real location, not the requested one.
onPlace = function(self, ctx)
self.state.owner = tostring(ctx.player)
self:save()
end,onLoad
Fires for every structure the scan starts tracking, including the one it just fired onPlace
for. ctx.reconstructed is true when the state came from a saved record and false for a
brand-new structure. This is the place for per-structure startup work.
onLoad = function(self, ctx)
if ctx.reconstructed then
log.info(self.key .. " restored with " .. tostring(self.state.uses) .. " use(s)")
end
self:render() -- normally unnecessary; the scan renders on its next pass
end,onRightClick
Driven by PalBuildObject:OnBeginInteractBuilding. Only PalCharacter subclasses count as the
one interacting, so building-to-building interactions never reach you, and repeat interactions
on the same actor are ignored for one second. The structure is found from ctx.actor.
onRightClick = function(self, ctx)
Item.get("Wood"):give(1)
log.info(tostring(ctx.player) .. " used " .. self.buildId)
end,onRemove
ctx.reason is "missing", the only reason the runtime gives today. The structure is still
tracked while your handler runs, so self.state and self.pos are readable; the record is
deleted immediately afterwards.
onRemove = function(self, ctx)
log.info(string.format("%s gone after %d use(s)", self.key, self.state.uses or 0))
end,onTick
The heartbeat is LoopAsync(500), published on the tick channel, and ctx.count is the
heartbeat number. Only structures whose definition actually writes an onTick are put on the
tick list, so declaring nothing costs nothing.
tickInterval divides that count: the handler runs when ctx.count % tickInterval == 0.
Building{
id = "example:Kiln",
tickInterval = 20, -- 20 heartbeats -> about 10 seconds
events = {
onTick = function(self, ctx)
if not self:isValid() then return end
log.info("tick " .. tostring(ctx.count))
end,
},
}A tickInterval that is not a whole number, or is below 1, is rounded back up to 1.
onTick has a circuit breaker. Every failure is logged and counted; after five failures the
structure is marked broken and stops ticking for the rest of its life, which means the rest of
the session unless it is removed and found again. One success resets the counter. Keep the
handler defensive — check self:isValid() before touching self.actor.
onBuild
Fires when the game finishes building a structure whose build id this definition claims. It
arrives up to one scan before the placed structure exists, and the game hands over a
UPalMapObjectModel rather than the actor, so self here is the definition, not a placed
structure: self.id, self.name, self.data and self:iconOf() are there, while
self.actor, self.pos, self.state and self:save() are not.
Building{
id = "example:Bench",
events = {
onBuild = function(self, ctx)
log.info(self.id .. " completed as " .. tostring(ctx.buildId))
end,
},
}onBuild only starts listening once the world has finished loading. Its native hook,
PalPlayerRecordData:OnCompleteBuild_ServerInternal, also fires for every structure that
already exists during the world-load rush, and reading a half-built UPalMapObjectModel there
faults in a way Lua cannot catch. Two things follow. In a session where the world never
finishes loading, onBuild never runs at all. And on a second world load in the same session
it is already listening, so it will be called during that rush. onPlace stays the safe
placement hook; try onBuild in a throwaway world first.
onWorldReady and onWorldLeft
Both go to every live structure, and both are world-load moments rather than per-structure ones.
world.ready arrives from the scan that turns actors into live structures, not the instant the
world opens. So the structures around the player are already tracked when your handler runs,
and they have already had their onLoad in that same pass. The wait is at most one scan —
500 ms — after the world opened.
Building{
id = "WorkBench",
name = "Workbench",
state = function() return { uses = 0, sessions = 0 } end,
events = {
onLoad = function(self, ctx)
self.state.uses = self.state.uses or 0 -- per-instance startup
end,
onWorldReady = function(self, ctx)
self.state.sessions = (self.state.sessions or 0) + 1
self:save()
log.info(string.format("%s present at load %d, %d use(s)",
self.key, self.state.sessions, self.state.uses))
end,
},
}onWorldReady fires once per world load, on the structures that first scan found. A structure
that streams in on a later scan misses it, and so does anything you place during the session.
Per-structure startup work belongs in onLoad, which fires for every structure the scan
tracks. Leaving the world before that first scan completes cancels the announcement, so a world
you passed through never announces itself.
onWorldLeft runs while the structures are still live and before they are dropped, so it is
the last chance to touch state — though the world cache is written for you right afterwards.
Subscribing to the channels directly
PalForge listens on these channels to call your handlers, and you can listen too. Use it for logic that spans many buildings at once:
local event = require("palforge.core.event")
event.on("building.place", function(ctx)
log.info("placed " .. tostring(ctx.buildId) .. " -> " .. tostring(ctx.key))
end)
event.observable("building.interact")
:filter(function(ctx) return ctx.buildId == "PalBoxV2" end)
:subscribe(function(ctx) log.info("pal box opened") end)
local sub = event.every(5000, function() log.info("five seconds") end)
sub:unsubscribe()event.every(ms, fn) is rounded to the 500 ms heartbeat, like the scan itself.
Handle actions and queries
Building{ ... }, Building.get and Building.get_all all hand you a Building.Handle. It
stands for the definition: use it to read what you declared, and to reach the structures placed
from it.
local box = Building.get("PalBoxV2")
box:name() --> "Pal Box" for the curated definition, else the id
box:description() --> the declared description, or nil
box:gridCm() --> the declared quantum, or nil when the runtime default applies
box:mesh() --> the declared mesh table, or nil
box:iconOf() --> a texture ref from DT_BuildObjectIconDataTable, else the declared iconunlock
Building.get("example:Bench"):unlock()Unlocks the technology row named after the resolved id (example_Bench) through
PalCheatManager:UnlockOneTechnology(FName). That is how a building tech injected by PalSchema
gets into the build menu.
This has never been observed working, and it is unverifiable by construction. A true does
not mean the technology is unlocked. It means the two things that can be read: the cheat call ran
without raising, and a technology row of that resolved name really exists in the live
DT_TechnologyRecipeUnlock — which is the check that stops the cheat "succeeding" for a building
with nothing to unlock, and is why Building.get("PalBoxV2"):unlock() is false (only 115 of the
501 vanilla build ids have such a row).
What cannot be read is the result. UnlockOneTechnology returns nothing, and no "is this
technology unlocked" accessor exists anywhere on this build — not on the cheat-manager surface,
not in the header dump, not in the item bridge. And it rides the same cheat-manager route that was
measured accepting a call and silently doing nothing, which is the exact failure this cannot
distinguish itself from.
The only way to settle it is to press it in a save and look at the build menu. That is the
declared hook pf_hook building-unlock — it needs a world and a player and it writes, because it
mutates the player's technology state.
false also comes back, with a distinct log line each time, when the technology table cannot be
read at all and when there is no cheat manager to call — this route uses whichever one already
exists and does not construct one.
instances, render, update
local kiln = Building.get("example:Kiln")
#kiln:instances() --> how many are placed in this world
kiln:render() --> attach the mesh to every live instance; returns how many attached
kiln:update() --> re-tint every live instance; returns how many were re-tintedBoth counts come from the per-structure method's own return value, not from whether the call
survived. A structure whose backend declined is not counted: no model in the mesh, an asset
that will not resolve, a kind that cannot dress a build object, no colour to apply, no live
material instance to write to. So render() returning 0 over a non-empty :instances()
means nothing attached, not that nothing threw.
render() is normally unnecessary — the scan attaches the mesh one pass after it first sees
the actor. Reach for it after changing what mesh() returns while the game runs.
The event forwarders
The handle also carries :onPlace(ctx), :onLoad(ctx), :onRightClick(ctx),
:onLeftClick(ctx), :onBuild(ctx), :onBreak(ctx), :onRemove(ctx) and :onTick(ctx), so
you can run a handler yourself.
A forwarder calls the handler with the definition class as self, not a placed structure.
self.state, self.actor, self.key and self:save() are absent there. That matches the
real event for onBuild and for nothing else, so use the forwarders to test a handler on its
own, and the real event — or an instance from :instances() — for anything else.
Recipes
A counter that persists across sessions
Place a workbench, use it a few times, quit, come back — the count is still there.
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("counter")
local Building = api.Building
return Building{
id = "WorkBench",
name = "Workbench",
gridCm = 100,
state = function() return { uses = 0 } end,
events = {
onLoad = function(self, ctx)
self.state.uses = self.state.uses or 0 -- records saved before the field existed
log.info(string.format("%s reconstructed=%s uses=%d",
self.key, tostring(ctx.reconstructed), self.state.uses))
end,
onRightClick = function(self, ctx)
self.state.uses = self.state.uses + 1
self:save()
log.info(self.key .. " used " .. self.state.uses .. " time(s)")
end,
onRemove = function(self, ctx)
log.info(string.format("%s removed after %d use(s), reason %s",
self.key, self.state.uses or 0, tostring(ctx.reason)))
end,
},
}The count survives because onRightClick calls save(). Without it the change would still be
visible in memory and would still be written the next time anything flushes the world file,
which is not a guarantee worth relying on.
A machine that consumes an item on a timer
Right-click toggles it. While running it eats one Wood every ten seconds and yields one Charcoal per three Wood.
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("kiln")
local Building, Item = api.Building, api.Item
local WOOD_PER_CHARCOAL = 3
return Building{
id = "example:Kiln",
name = "Slow Kiln",
description = "Burns wood into charcoal on its own.",
gridCm = 100,
tickInterval = 20, -- 20 heartbeats -> about 10 seconds
state = function() return { wood = 0, charcoal = 0, running = true } end,
events = {
onPlace = function(self, ctx)
log.info("kiln built at " .. self.key)
self:save()
end,
onRightClick = function(self, ctx)
self.state.running = not self.state.running
self.color = self.state.running
and { r = 0.9, g = 0.4, b = 0.1, a = 1.0 }
or { r = 0.3, g = 0.3, b = 0.3, a = 1.0 }
self:update()
self:save()
log.info(self.key .. " running=" .. tostring(self.state.running))
end,
onTick = function(self, ctx)
if not (self.state.running and self:isValid()) then return end
if not Item.get("Wood"):take(1) then return end
self.state.wood = self.state.wood + 1
if self.state.wood >= WOOD_PER_CHARCOAL then
self.state.wood = self.state.wood - WOOD_PER_CHARCOAL
self.state.charcoal = self.state.charcoal + 1
Item.get("Charcoal"):give(1)
log.info(string.format("%s produced charcoal #%d", self.key, self.state.charcoal))
end
self:setDirty() -- staged; the next save or the world unload writes it
end,
},
}:take moves the count through the game's own server path and returns whether the call
executed, not whether the player actually had that much. Track what you consumed in state if
the machine needs a real balance.
A structure that spawns a pal when interacted with
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("summon")
local Building, Pal = api.Building, api.Pal
local COOLDOWN_TICKS = 60 -- heartbeats -> about 30 seconds
return Building{
id = "PalBoxV2",
name = "Pal Box",
gridCm = 100,
mesh = {
kind = "static",
model = "/Game/Pal/Model/Other/PalBox/SM_PalBox.SM_PalBox",
},
state = function() return { summons = 0, cooldown = 0 } end,
events = {
onTick = function(self, ctx)
if (self.state.cooldown or 0) > 0 then
self.state.cooldown = self.state.cooldown - 1
end
end,
onRightClick = function(self, ctx)
if (self.state.cooldown or 0) > 0 then
log.info("on cooldown for " .. self.state.cooldown .. " more tick(s)")
return
end
local at = { x = self.pos.x, y = self.pos.y + 300, z = self.pos.z + 100 }
if Pal.get("ChickenPal"):spawn(at) then -- issued; the pal arrives seconds later
self.state.summons = self.state.summons + 1
self.state.cooldown = COOLDOWN_TICKS
self:save()
log.info(string.format("summon #%d from %s", self.state.summons, self.key))
end
end,
},
}The if runs its body as soon as the spawn call is issued, which is several seconds before the
pal turns up — so the counter and the cooldown move first and the creature follows. That is the
right order here: the post has committed to the summon. native/buildings.lua registers nothing,
so this is the only definition of PalBoxV2; its mesh is restated here because a definition
carries only what it declares. The vanilla pal box UI still opens — the interact hook watches the
call, it does not swallow it.
Summary
- Write
Building{ ... }to define one. Onlyidis required, and it has to be an id the game already has. - Put anything you want remembered in
state, change it in place, then callself:save(). - Handlers go in
events, and the first argument is the placed structure, soself.state,self.posandself.actorare right there. onPlace,onLoad,onRightClick,onRemove,onTick,onBuild,onWorldReadyandonWorldLeftall run.onLeftClickandonBreaknever do, and that is measured, not pending.- A structure is found again by its rounded world position, so
gridCmdecides how close two of them can stand. The record that key names carries the definition that owns it, and the file it is written into is the owning mod. - Registering a building is a write: only a registered definition is tracked, and only a tracked
structure is persisted.
native/buildings.luaregisters nothing until youpublishit. Building.get(id):instances()gives you every structure of that definition standing in the world right now, andself:neighbors(cm)gives one structure the ones standing around it.
Next, read Item for :give and :take, which is how a building hands things
to the player.