PalForge
API reference

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.Building

There 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 one

The 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:

content/buildings/bench.lua
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 data

Nothing 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, data
PalForge: 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 tooling

The 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 owner

register = 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 instance

The 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,78

gridCm 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 icon

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

The 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, returning true, or false and 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,
}
EventChannelFiresctx
onPlacebuilding.placeLIVEkey, actor, pos, buildId, player, firstSeen
onLoadbuilding.loadLIVEkey, actor, pos, buildId, reconstructed
onRightClickbuilding.interactLIVEactor, player, buildId
onRemovebuilding.removeLIVEkey, buildId, actor, reason
onTicktickLIVEcount, now
onBuildbuilding.buildLIVE, once the world has loadedbuildId, model
onWorldReadyworld.readyLIVEempty table
onWorldLeftworld.leftLIVEempty table
onLeftClicknever
onBreaknever

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.

content/buildings/sessions.lua
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 icon

unlock

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-tinted

Both 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.

content/buildings/counter.lua
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.

content/buildings/kiln.lua
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

content/buildings/summon_post.lua
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. Only id is required, and it has to be an id the game already has.
  • Put anything you want remembered in state, change it in place, then call self:save().
  • Handlers go in events, and the first argument is the placed structure, so self.state, self.pos and self.actor are right there.
  • onPlace, onLoad, onRightClick, onRemove, onTick, onBuild, onWorldReady and onWorldLeft all run. onLeftClick and onBreak never do, and that is measured, not pending.
  • A structure is found again by its rounded world position, so gridCm decides 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.lua registers nothing until you publish it.
  • Building.get(id):instances() gives you every structure of that definition standing in the world right now, and self: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.

On this page