PalForge
Concepts

Definitions and handles

How to make a piece of content, what you get back, and how to find it again

What you can do after this page

  • Add your own pal, item, building, sound or mesh to the game
  • Give something already in Palworld new behaviour, without declaring a thing
  • Find anything you made earlier from anywhere else in your pack
  • Pick names that will not clash with another player's mods, and hear about it when they do
  • Read the message PalForge prints when a field is wrong, and fix it

Making something

You make content by calling the module for that kind of thing with a table of settings. Every kind works the same way:

local pal = Pal{ id = "example:Boss", name = "Boss" }  -- CALL the module to define
Pal.get("ChickenPal")                                  -- an existing one, by id
Pal.get_all()                                          -- every registered one

The call writes your settings down as a definition and hands back a handle: an object you can act on. Item, Building, Skill, Effect, Audio, Mesh and UI are called the same way. Only the fields differ.

Pal{ ... } is Lua shorthand. When a call's only argument is a table, you may leave the parentheses out, so the two forms below are the same call:

local pal = Pal{
    id          = "ChickenPal",
    name        = "Chicken Pal",
    description = "the one that greets you",
}

The braces are the argument list. Every value carries a name, so the order does not matter, and a field added to a domain later can never shift the meaning of the ones you already wrote.

The same shorthand works for any function that takes one table, so the named constructors read the same way:

Audio.bgm{ id = "AKE_BGM_Title" }     -- same as Audio.bgm({ id = "AKE_BGM_Title" })
Panel:new{ title = "Hello" }          -- same as Panel:new({ title = "Hello" })

A module can be called because its table carries a __call metamethod, Lua's hook for "somebody called this table":

setmetatable(Pal, { __call = function(_, spec) return define(spec) end })

define is a local function inside api/pal.lua, so calling the module is how you reach it.

Pal() with no argument is not a shortcut for an empty definition. The spec becomes an empty table, id is missing, and the call errors.

What you get back

The handle is the object you act through. Everything you want to do to a pal lives there:

local boss = Pal{ id = "ChickenPal", name = "Scorched Chicken" }

boss:spawn(Player.coordinate())   -- put one in the world
boss:name()                       -- "Scorched Chicken"
boss.id                           -- "ChickenPal"

Pal.Handle:spawn, Item.Handle:give, Audio.Handle:play, Effect.Handle:apply, Mesh.Handle:attachTo and UI.Handle:new are all methods on a handle.

Inside, a handle is a two-field wrapper around the definition it stands for:

wrap = function(cls) return setmetatable({ id = cls.id, _cls = cls }, Handle) end

id is public, _cls is the definition, and the Handle metatable carries that domain's actions and queries. Handles are cheap and disposable — every get call builds a fresh one:

local a = Pal.get("ChickenPal")
local b = Pal.get("ChickenPal")

a == b        -- false: two different wrapper tables
a.id == b.id  -- true:  the same definition underneath

Your event handlers are called with that same handle as the first argument, so whatever just happened, the actions for that object are already in your hand:

Pal{
    id = "ChickenPal",
    events = {
        onSpawned = function(pal, ctx)
            pal:renderOn(ctx.actor)          -- `pal` is this definition's handle
        end,
    },
}

A UI handle carries one thing more. wrap also builds a state table with the element class as its metatable and stores it in _st, so the handle is itself mountable, and Handle:new{ ... } produces an independent instance with its own state.

What gets stored

The definition is a plain Lua table holding what you passed, with the domain's base class as its metatable, recorded in core/object_manager under the pair (type, id).

For a pal, Pal{ ... } builds this:

local cls = setmetatable({
    id           = spec.id,
    name         = spec.name or spec.id,
    description  = spec.description,
    skills       = spec.skills,
    meshSpec     = spec.mesh,
    materialSpec = spec.material,
    color        = spec.color,
    texture      = spec.texture,
    icon         = spec.icon,
    data         = spec.data,
}, Class)
cls.__index = cls

Two fields are stored under a different name. mesh becomes cls.meshSpec and material becomes cls.materialSpec, because Class:mesh() and Class:material() are methods and would collide. A building's state field lands as cls.defaultState, because on a live building state is that one building's own saved table. You only meet those names if you inspect a class directly.

The handlers you declared are installed onto that class. Pals, items, skills and effects install each one behind a small forwarder, which is what makes the handler receive the handle rather than the class:

for name, handler in pairs(spec.events or {}) do
    cls[name] = function(_, ...) return handler(handle, ...) end
end

Buildings install handlers exactly as written, because a building handler's first argument is the live placed building, not the definition:

for name, handler in pairs(spec.events) do cls[name] = handler end

Then the class is registered, together with whichever pack is defining right now:

om.register("pal", spec.id, cls, { pack = pack })

register answers the class, or nil plus a reason. It never throws. Six of the eight domains call it best effort and drop the answer, so a registry problem cannot break your definition call. Two say something instead, because a definition that did not register is one nothing will ever find: api/pal.lua reads the answer, and api/ui.lua catches a raise and names what stops working.

[PalForge.pal][err] Pal 'example:Boss' could NOT be registered (...) — it will receive no lifecycle events and Pal.get will not find it

The key is the id exactly as you wrote it, colon included: "example:Boss" is stored under "example:Boss". The resolved spelling example_Boss goes into a second index beside it, which is what dispatch matches a game row name against.

You can read the registry directly, and ask it who owns what:

local om = require("palforge.core.object_manager")

om.get("pal", "ChickenPal")          -- the definition class, or nil
om.all("pal")                        -- { id -> cls }, a shallow copy you may not mutate into
om.TYPES                             -- audio, building, effect, item, mesh, pal, skill, ui

om.isRegistered("pal", "ChickenPal") -- always a boolean: is this id taken
om.owner("pal", "example:Boss")      -- the pack id that registered it, or nil
om.entry("pal", "example:Boss")      -- a copy of { cls = , pack = , resolved = }
om.byResolved("pal", "example_Boss") -- cls, sourceId - one table read, not a scan
om.unregister("pal", "example:Boss") -- true when something was there to forget
om.validId("my-pack:Bench")          -- false, plus the reason

om.isRegistered is the only public answer to "is this id taken", because X.get cannot give one: seven domains fabricate a thin handle on a miss and Mesh.get raises. om.all and om.entry hand back copies, so writing into one changes nothing.

One call, end to end

Checking comes first, and it either passes completely or raises. A call never half-succeeds, so you can never end up with a definition registered under settings that were rejected. The checker looks for unknown keys across the whole table before it inspects any single field, then walks the declared fields in order, filling defaults into a fresh copy. Your table is never changed.

Every problem is a hard error naming the domain and the field, so you can usually fix it from the message alone:

PalForge: Pal: unknown field "meshSpec" (did you mean "mesh"?). Valid fields: id, name, description, skills, mesh, material, color, texture, icon, events, data
PalForge: Pal: field "id" is required (pal id: a game CharacterID ("ChickenPal") or "pack:name")
PalForge: Pal: field "name" expects string, got number
PalForge: Pal: field "skills[2]" expects string, got number
PalForge: Pal: field "mesh" (Mesh.Spec): field "model" is required (USkeletalMesh / UStaticMesh asset path)
PalForge: Item: field "category" must be one of { "material", "consumable", "equipment", "ammo", "ingredient", "other" }, got "consumeable"

To see what a call accepts before you write it, ask the schema registry — the runtime list of every declared shape:

local schema = require("palforge.core.schema")

print(schema.help("Pal.Spec"))        -- every field, type, default and meaning
schema.get("Pal.Spec").fields         -- the same, as a table, for tooling
schema.all()                          -- every declared spec, in declaration order

Finding something again

X{ ... } is the only thing that creates a definition. get and get_all only look things up:

Item{ id = "Wood", name = "Wood", maxStack = 9999 }   -- defines and registers
Item.get("Wood")                                      -- looks it up
Item.get_all()                                        -- every registered item

X.get(id) asserts that id is a non-empty string, then looks in the registry. What happens when nothing is registered under that id is the one place the domains differ:

Seven domains build a thin fallback: a bare class carrying only the id, wrapped in a handle. That is what lets you act on the game's own content without declaring anything:

Item.get("Wood"):give(10)                              -- no Item{ id = "Wood" } needed
Pal.get("SheepBall"):spawn(Player.coordinate())
Audio.get("AKE_BGM_Title"):play()

A thin handle still does everything that only needs the id. :spawn, :give, :take and :play all go through the id, and in the domains that read an icon DataTable — the game's table of item and creature icons — :iconOf() resolves from it as usual. That covers pal, item, building and skill. What a thin handle cannot do is anything that came from a declaration: :mesh() is nil, :renderOn(actor) returns false, :name() falls back to the id, and no handler runs.

Audio.get is the one thin fallback with a second field, soundId = id, so any AkAudioEvent name — the game's own name for a sound — is playable straight away. The same fallback Audio{ ... } applies to a definition that names no sound.

Mesh.get raises instead:

PalForge: Mesh.get("example:body"): no mesh is defined under that id

A mesh with no model has nothing to render, so a missing mesh id fails where you wrote it rather than quietly attaching nothing somewhere else.

local log = require("palforge.utils.log").scope("mypack")

local ok, err = pcall(Mesh.get, "example:body")
if not ok then log.warn(err) end

X.get_all() walks om.all(type) and wraps every class it finds:

local log = require("palforge.utils.log").scope("mypack")

for _, item in ipairs(Item.get_all()) do
    log.info(item.id .. " -> " .. item:name() .. " (" .. item:category() .. ")")
end

Two things to expect from it. The order is a pairs walk over a snapshot, so it is not stable — sort the result if you need one. And it lists only what has actually been defined: the native catalogs load on demand, so right after startup Item.get_all() gives you the curated definitions (Wood, Berries, Arrow) plus whatever your pack declared, not every row in the game's item table.

Define once, act many times

A definition call registers. A handler runs on every event. Reach for get inside a handler, never for a define:

Pal{
    id = "ChickenPal",
    events = {
        onDeath = function(pal, ctx)
            Audio.get("AKE_BGM_Title"):play()          -- a lookup, then a play
            -- Audio.bgm{ id = "AKE_BGM_Title" }:play()  -- re-declares on every death
        end,
    },
}

Both spellings play the sound. The second one also re-checks a spec and overwrites the registry entry every single time the pal dies.

Ids: your own, and the game's

An id with a colon is a pack id: "packid:name". In the game's DataTables the matching row FName is packid_name — the two injected rows in the dumped DT_ItemDataTable are spelled exactly PalSmith_TestPotion and example_Potion, which is where that rule is checked rather than assumed. PalSchema rows are shared across every mod, so that prefix is what keeps two packs from colliding.

An id without a colon is a literal game id: "Wood", "ChickenPal", "PalBoxV2", "WorkBench", "AKE_BGM_Title".

local om = require("palforge.core.object_manager")

om.resolve("example:Bench")   -- "example_Bench"
om.resolve("PalBoxV2")        -- "PalBoxV2"
om.resolve("my pack:Bench")   -- nil, "invalid pack id 'my pack' (letters/digits/_ only)"

Both halves of a namespaced id must be letters, digits or underscores, and that shape is checked at define time as well as at resolve time. A hyphen is the shape that costs most, so it is worth writing out: Building{ id = "my-pack:Bench" } cannot resolve, so no build id would ever enter the scan's index, so onPlace / onLoad / onTick / onRemove could never fire — for a definition that returned a live-looking handle. It is a hard error now, naming the rule:

PalForge: Building: field "id" is invalid: invalid pack id 'my-pack' in 'my-pack:Bench' (letters/digits/_ only)

Ids are scoped per DOMAIN. The registry is keyed on the pair (type, id), so Pal{ id = "Ash" } and Item{ id = "Ash" } are two separate entries and cannot collide. What CAN collide is two ids in one domain that resolve to the same row name, and that is warned about at define time.

Resolving happens where a game row name is needed, not in the registry:

  • Building.Handle:unlock() resolves the id before unlocking the technology row.
  • core/event resolves a building's buildIds before matching placed actors against them.
  • :iconOf(), the passive-skill writes and the audio catalog lookup all resolve first, so Item{ id = "pack:Potion" }:iconOf() reaches the real row.

The registry key stays the id as written, which matters when you look one up:

Building{ id = "example:Bench" }

Building.get("example:Bench")   -- the definition
Building.get("example_Bench")   -- a thin fallback: nothing is registered under that key

Saying which pack is defining

A definition call is a plain Lua call and carries no evidence of which mod made it, so nothing can attribute a registration — or tell a pack overwriting its own id from a pack overwriting someone else's — unless the pack says so. PalForge.pack(packId) is where it says so:

local mine = PalForge.pack("mypack", { depends = { "otherpack" } })
local Item = mine.Item

Item{ id = "mypack:Potion" }     -- registered with pack = "mypack"

It hands back the same nine members. The eight constructors are wrapped so the define runs with the pack recorded, and so are the extra define routes a domain adds as named functions (Audio.bgm, Audio.se), because those register too. Everything else — X.get, X.get_all, Player — passes through untouched, and the scoped table is a read-only view: assigning onto it is an error rather than a silent divergence from the module every other caller sees.

Using it is optional. A pack that does not gets pack = nil on its entries, which is a perfectly good record meaning "declared by no pack in particular" — it only means a collision with it cannot name an owner.

Defining a namespaced id gives that id behaviour and metadata. It does not add a new DataTable row to the game — Lua alone cannot do that, which is PalSchema's job. Point a definition at an id that already exists, whether that is a vanilla one or a row your pack ships through PalSchema.

Nesting a mesh inside another definition

A mesh is the model something wears. You can write it inline inside the definition that wears it, or declare it once by name and pass it in as itself. Both reach core/mesh the same way.

Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    },
}

The named form works because Mesh.Handle's metatable carries a __spec metafield:

Handle.__spec = function(self) return self._cls:source() end

The checker looks for that metafield and, when it finds one, checks the table it returns instead of the handle:

Two consequences worth knowing.

First, the copy is real. Pal.Handle:mesh() returns the copy stored on the pal definition, not the mesh definition itself, so changing it does not reach back into the Mesh you declared.

local body = Mesh{ id = "example:Body", model = "/Game/.../SK_X.SK_X", scale = 1.0 }
local pal  = Pal{ id = "example:Boss", mesh = body }

pal:mesh().scale = 4.0
body:source().scale     -- still 1.0

Second, Mesh.Handle is the only handle that carries __spec. A mesh is the one defined object you can nest inside another definition today. Where any other domain's shape is expected, pass a plain table.

Building.Spec.Mesh is Mesh.Spec with kind defaulting to "static" instead of "skeletal". A default only applies to a field you left out, and a named Mesh{ ... } already had its own default filled in when it was defined. So a mesh handle nested into a building arrives carrying kind = "skeletal", and the building's "static" default never fires. Set kind explicitly on any mesh a building will wear.

local BenchBody = Mesh{
    id    = "example:BenchBody",
    kind  = "static",   -- explicit: this mesh is worn by a building
    model = "/Game/Pal/Model/Prop/Architecture/WorkBenchPrimitive/SM_WorkBenchPrimitive.SM_WorkBenchPrimitive",
}

Building{ id = "WorkBench", name = "Workbench", gridCm = 100, mesh = BenchBody }

Defining the same id twice

Defining the same (type, id) twice replaces the entry. Last-wins is the policy and it stays the policy; what the registry adds is that the replacement is visible and, when the packs said who they are, attributable.

local first  = Item{ id = "Wood", name = "Wood" }
local second = Item{ id = "Wood", name = "Seasoned Wood" }

first:name()              -- "Wood"          (the first class, still held by that handle)
second:name()             -- "Seasoned Wood"
Item.get("Wood"):name()   -- "Seasoned Wood" (the registry holds the second)

Three cases, and they are logged differently on purpose:

  • Two packs, one id. A warning naming the type, the id, the pack that held it, the pack taking it, and the outcome.
  • One pack redefining its own id. Silent at warning level. It is the ordinary case — an F9 reload, the test suites, a native catalog materialising a row again — so it is an info line gated on env.debug, not a warning an author has to read hundreds of.
  • Two ids, one game row. "my:pack_Thing" and "my_pack:Thing" both resolve to my_pack_Thing, so one row would carry two definitions. A warning names both source ids and says which one now owns the resolved lookup. Rename one of them.
[PalForge.objects][warn] item 'Wood' was defined by pack 'first' and is being redefined by pack 'second'; the new definition replaces the old one (last-wins)
[PalForge.objects][warn] item ids 'my:pack_Thing' and 'my_pack:Thing' both resolve to the single game row 'my_pack_Thing' — one row, two definitions. 'my_pack:Thing' now owns the resolved lookup; rename one of them

Attribution needs a name to print: without PalForge.pack("mypack") both sides register with pack = nil, which reads as the same owner, and the collision is logged as a quiet redefinition instead of a warning.

A registration can also be taken back, with om.unregister(type, id) — it answers whether anything was there.

What follows from a replacement:

  • X.get, X.get_all and event dispatch all go through the registry, so they see the newest definition.
  • A handle from an earlier call keeps its own _cls. Its queries and its manual :onXxx forwarders still run the earlier declaration. Its id-only actions (:spawn, :give, :play) behave the same either way, because they only ever use self.id.
  • For buildings, core/event caches a lowered def per class and rebuilds it on the next scan when the class changed. A structure that is already tracked keeps the class it was created with; the new definition applies to buildings placed afterwards.

The reliable pattern is to define each id once, at load, and use X.get everywhere else. If you are hot-reloading a pack file while you work, expect the registry to hold the last version loaded, and expect old handles you kept in locals to be stale.

X.Class

Every callable module exposes X.Class: the base class every definition of that domain gets as its metatable. It holds the do-nothing hook defaults plus the domain's own methods — Pal.Class:mesh, Pal.Class:material, Pal.Class:iconOf, Building.Class:render, Building.Class:update, Building.Class.new, Mesh.Class:source, Audio.Class:source, UI.Class:mount, and so on.

Most packs never touch it. Two situations where you do.

Override detection. core/event decides whether a building declared a hook by comparing the class against the base:

local BuildingBase = require("palforge.api.building").Class

local function overrides(cls, name)
    return cls[name] ~= nil and cls[name] ~= BuildingBase[name]
end

A definition that never declared onTick inherits the do-nothing default, compares equal, and is left out of the tick list entirely. The flip side is that an empty handler is not free:

Building{
    id     = "example:Bench",
    events = {
        onTick = function(self, ctx) end,   -- differs from the base: this instance ticks
    },
}

Sharing behaviour across definitions. Methods resolve through the metatable chain — a live building falls back to its definition class, which falls back to Building.Class — so a method added there is visible to every definition and every live building, including ones created before you added it.

function Building.Class:describe()
    return (self.name or self.id) .. " at " .. tostring(self.key)
end

Per-definition values do not belong there. Use the data field, which every domain but Mesh declares and carries onto the definition untouched. UI treats it differently: its data keys are spread onto the element class as defaults every instance inherits.

local Boss = Pal{
    id   = "ChickenPal",
    data = { tier = 3, drops = { "Wood", "Berries" } },
}

Every domain at a glance

DomainX{ ... } returnsX.get(id) with nothing registeredHandler selfBeyond define / get / get_all
PalPal.Handlethin handle over { id = id }this definition's handlePal.Class
ItemItem.Handlethin handle over { id = id }this definition's handleItem.Class
BuildingBuilding.Handlethin handle over { id = id }the live placed instanceBuilding.Class
SkillSkill.Handlethin handle over { id = id }this definition's handleSkill.Class
EffectEffect.Handlethin handle over { id = id }this definition's handleEffect.activeOn, Effect.Class
AudioAudio.Handlethin handle over { id = id, soundId = id }no events in this domainAudio.bgm, Audio.se, Audio.Class
MeshMesh.Handlehard errorno events in this domainMesh.Class
UIUI.Handle, itself mountablethin, inert elementthe mounted instanceUI.Class, Handle:new
Playernot callableno getno events in this domaincharacter, coordinate, coordinateOffset

Mesh also differs in what a definition may leave out. Mesh.Spec is the one shape whose id is optional, because an inline mesh has nothing to name — but defining one directly still requires it:

PalForge: Mesh: field "id" is required (an unnamed mesh cannot be looked up again - write it inline as mesh = { ... } instead)

Audio.bgm and Audio.se are the same define with kind pinned. They copy your table rather than changing it, and a contradicting kind is rejected instead of overwritten:

Audio.bgm{ id = "AKE_BGM_Title" }            -- kind = "bgm"
Audio.se{ id = "AKE_Build_PalBox" }          -- kind = "se"
Audio.bgm{ id = "AKE_Cheer", kind = "se" }   -- error: kind is fixed to "bgm" here

Recipes

A pack file, from mesh to spawn

Scripts/mypack/content.lua
require("palforge.api")   -- installs Pal / Item / Mesh / Audio / Player as mod-local globals

local log = require("palforge.utils.log").scope("mypack")

-- 1. one named mesh, reusable by id
local BossBody = Mesh{
    id        = "mypack:BossBody",
    kind      = "skeletal",
    model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
    animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    scale     = 2.0,
    color     = { r = 1.0, g = 0.4, b = 0.2, a = 1.0 },
}

-- 2. a definition that wears it
local Boss = Pal{
    id          = "ChickenPal",
    name        = "Scorched Chicken",
    description = "A chicken that has seen things.",
    mesh        = BossBody,
    data        = { tier = 3 },
    events = {
        onSpawned = function(pal, ctx)
            pal:renderOn(ctx.actor)
            Audio.get("AKE_BGM_Title"):play()
        end,
        onDeath = function(pal, ctx)
            Item.get("Wood"):give(5)
            log.info(pal:name() .. " dropped its wood")
        end,
    },
}

-- 3. act on it through the handle the define returned
Boss:spawn(Player.coordinateOffset(300, 0, 0))

A building that reuses the same named mesh style

Scripts/mypack/bench.lua
require("palforge.api")

local BenchBody = Mesh{
    id    = "mypack:BenchBody",
    kind  = "static",
    model = "/Game/Pal/Model/Prop/Architecture/WorkBenchPrimitive/SM_WorkBenchPrimitive.SM_WorkBenchPrimitive",
}

local Bench = Building{
    id           = "WorkBench",
    name         = "Workbench",
    gridCm       = 100,
    tickInterval = 4,
    mesh         = BenchBody,
    state        = { uses = 0 },
    events = {
        onPlace = function(self, ctx)
            self.state.uses = 0
            self:save()
        end,
        onRightClick = function(self, ctx)
            self.state.uses = self.state.uses + 1
            self:save()
        end,
    },
}

Bench:unlock()                                  -- resolves the id, unlocks the tech row
local placed = #Bench:instances()               -- how many are live in the loaded world

A building handler takes self as the live placed building, which is why self.state and self:save() are there. See the lifecycle page for what drives each hook.

Inspecting what is registered

a console-driven dump
local om     = require("palforge.core.object_manager")
local schema = require("palforge.core.schema")
local log    = require("palforge.utils.log").scope("mypack")

-- what a call accepts
log.info(schema.help("Pal.Spec"))
log.info(schema.help("Mesh.Spec"))

-- what exists right now, per object type
for _, otype in ipairs(om.TYPES) do
    local ids = {}
    for id in pairs(om.all(otype)) do ids[#ids + 1] = id end
    table.sort(ids)
    log.info(otype .. " (" .. #ids .. "): " .. table.concat(ids, ", "))
end

Defining safely over content that may not be yours

local function meshOrNil(id)
    local ok, handle = pcall(Mesh.get, id)
    return ok and handle or nil
end

Pal{
    id   = "example:Boss",
    mesh = meshOrNil("otherpack:BossBody") or {
        model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
    },
}

Mesh.get is the only lookup that raises, so it is the only one worth a pcall. Every other get returns a handle no matter what.

Where to go next

Summary

  • Call the module with a table to make something: Pal{ id = "example:Boss" }. id is always required.
  • The call returns a handle. Actions such as :spawn, :give, :play and :apply live on the handle, and your handlers receive it as their first argument.
  • X.get(id) finds one again. Seven domains give you a thin, id-only handle on a miss, so vanilla content works without being declared; Mesh.get raises instead.
  • An id with a colon is yours ("mypack:Bench"); an id without one is the game's ("Wood"). Both halves of a namespaced id are letters, digits and _, checked at define time, and ids are scoped per domain.
  • A wrong or misspelled field is a hard error naming the field, and nothing is registered.
  • Defining an id twice replaces it, and the registry says so: a cross-pack collision names both owners, and two ids resolving to one game row name both. Declare a pack with PalForge.pack("mypack") so those messages can name yours.
  • Define each id once at load, and use X.get inside handlers.

Next, read Lifecycle to see which events actually reach your handlers.

On this page