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 oneThe 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) endid 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 underneathYour 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 = clsTwo 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
endBuildings 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 endThen 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 itThe 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 reasonom.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 orderFinding 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 itemX.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 idA 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) endX.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() .. ")")
endTwo 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/eventresolves a building'sbuildIdsbefore matching placed actors against them.:iconOf(), the passive-skill writes and the audio catalog lookup all resolve first, soItem{ 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 keySaying 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() endThe 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.0Second, 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 tomy_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 themAttribution 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_alland 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:onXxxforwarders still run the earlier declaration. Its id-only actions (:spawn,:give,:play) behave the same either way, because they only ever useself.id. - For buildings,
core/eventcaches 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]
endA 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)
endPer-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
| Domain | X{ ... } returns | X.get(id) with nothing registered | Handler self | Beyond define / get / get_all |
|---|---|---|---|---|
Pal | Pal.Handle | thin handle over { id = id } | this definition's handle | Pal.Class |
Item | Item.Handle | thin handle over { id = id } | this definition's handle | Item.Class |
Building | Building.Handle | thin handle over { id = id } | the live placed instance | Building.Class |
Skill | Skill.Handle | thin handle over { id = id } | this definition's handle | Skill.Class |
Effect | Effect.Handle | thin handle over { id = id } | this definition's handle | Effect.activeOn, Effect.Class |
Audio | Audio.Handle | thin handle over { id = id, soundId = id } | no events in this domain | Audio.bgm, Audio.se, Audio.Class |
Mesh | Mesh.Handle | hard error | no events in this domain | Mesh.Class |
UI | UI.Handle, itself mountable | thin, inert element | the mounted instance | UI.Class, Handle:new |
Player | not callable | no get | no events in this domain | character, 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" hereRecipes
A pack file, from mesh to spawn
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
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 worldA 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
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, ", "))
endDefining 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
Pal
Mesh
Building
Lifecycle
Summary
- Call the module with a table to make something:
Pal{ id = "example:Boss" }.idis always required. - The call returns a handle. Actions such as
:spawn,:give,:playand:applylive 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.getraises 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.getinside handlers.
Next, read Lifecycle to see which events actually reach your handlers.