PalForge
API reference

Pal

Add a creature that reacts when it spawns, takes damage or is caught

What you can do after this page

  • Make a creature react the moment you catch it, hit it or kill it
  • Play a sound, or start a timed effect, when a pal dies
  • Give a pal a different body, a different colour and a different size
  • Drop a pal into the world where you are standing, at the level you pick
  • Run your own code on every one of your pals, over and over, while the game is playing

Make a pal

A pal is one of the game's creatures. Write Pal{ ... } with the game's own name for it, and you get back an object you can spawn, look up later and hang behaviour on.

content/pals.lua
local pal = Pal{
    id          = "ChickenPal",
    name        = "Chicken Pal",
    description = "the one that greets you",
    events = {
        onCaptured = function(pal, ctx)
            Audio.get("AKE_Arena_Victory_01"):play(ctx.actor)
        end,
    },
}

pal:spawn(Player.coordinate())   -- the pal turns up a few seconds later

Catch a Chicken Pal in your game and the victory jingle plays. That is the whole loop: say which creature you mean, say what should happen to it, and PalForge does the rest.

There are three ways in:

Pal{ id = "ChickenPal" }    -- define + register, returns a Pal.Handle
Pal.get("SheepBall")        -- a handle for an existing id; never nil
Pal.get_all()               -- every PalForge-registered pal, as handles

Pal.get never returns nil. An id nobody defined gets a thin definition — enough to :spawn it, but with no handlers on it. Only Pal{ ... } registers a pal, and only a registered pal ever gets its handlers called.

Palworld's own creature list lives in native/pals:

local pals = require("palforge.native.pals")

pals.CATALOG               -- every DT_PalMonsterParameter_Common row id, as strings
pals.get("BlueSkyDragon")  -- a lazy Pal handle for any catalog id, nil for anything else
pals.Chicken               -- the curated ChickenPal demo definition
pals.SheepBall             -- the curated SheepBall demo definition

pals.get(id) defines and caches a bare Pal{ id = id } the first time you ask for it. Asking for a catalog id therefore registers it too, so events start reaching that id — with empty handlers until you give it some.

Fields

id is the only field you have to fill in. Anything not on this list is an error the moment you call Pal{ ... }, with a did-you-mean suggestion, so a typo shows up straight away instead of being quietly ignored. The same list is readable while the game runs, with require("palforge.core.schema").help("Pal.Spec").

Prop

Type

No field has a default. Two of them fall back when you read them: :name() gives you the id when you left name out, and :skillsOf() gives you an empty table when you left skills out.

An id with a colon in it is your own: "example:Boss" matches the game data row named example_Boss. An id without a colon is one of the game's own, such as ChickenPal.

mesh

A mesh is the 3D model a creature wears. Write it inline, or define it once with Mesh{ ... } and pass that. Both forms are checked the same way and reach the renderer the same way, so pick whichever suits the file.

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 mesh fields are kind (procedural, static, skeletal or obj; defaults to skeletal), model (required), animClass (skeletal only), scale, offset, texture, color, material and params. obj is another name for the procedural backend.

A declared mesh attaches itself. Pal{ ... } wraps the definition's onSpawned so that renderOn runs on the pal.spawned channel, before your own handler, for every pawn of that id. Declaring the mesh is the whole of it:

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",
    },
}

pal.spawned is the earliest moment the pawn's .Mesh component is real, which is why the attach rides that channel rather than the onTick sweep — a mesh needs setting once, and the sweep would pay for it every three seconds forever. The attach goes through attachOnce, so a second spawn event for one pawn does not stack a second mesh, and the whole of it is inside a pcall: a model path that will not resolve costs the pal a log line, never its lifecycle.

You still call :renderOn(actor) yourself for a pawn PalForge did not spawn, for one you found through the onTick sweep, and to re-apply after a detach.

material, color and texture

material is the full override. color and texture are short spellings for two of its fields, for when that is all you need.

Prop

Type

The two spellings are not mixed together. When you give material, it is used whole and the top-level color and texture are ignored. Only when material is absent are the short spellings assembled into a material description. Inside :renderOn, whatever that description carries wins over the same field on the mesh, and a field it leaves out keeps the mesh's own value.

-- shorthand: tint the declared mesh red
Pal{
    id    = "ChickenPal",
    mesh  = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal" },
    color = { r = 1.0, g = 0.2, b = 0.2, a = 1.0 },
}

-- long form: a base material plus parameters, which the shorthands cannot express
Pal{
    id   = "SheepBall",
    mesh = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall" },
    material = {
        color    = { r = 0.2, g = 0.5, b = 1.0, a = 1.0 },
        texture  = "/Game/Pal/Model/Other/AttackHelicopter/Material/T_AttackHelicopter_B.T_AttackHelicopter_B",
        material = "/Game/.../MI_YourBaseMaterial",
        params   = { scalar = { ["Roughness Add"] = 0.2 } },
    },
}

There is no top-level short spelling for params or for a base material path. Those live inside material only. params is grouped by type — vector, scalar, texture — because that is what the three engine setters behind it take.

A pal's own texture (both spellings) reaches the renderer exactly as you wrote it: a /Game/... texture asset, which needs no file on the player's disk, or an absolute png of your own. A path relative to your pack's directory is resolved only inside a mesh's own texture field, not here.

All four fields reach every backend, skeletal included. The material work lives in core/mesh/base/renderer.lua and each backend calls it, so a color beside a kind = "skeletal" mesh is written to that pawn's own material slots through a dynamic material instance.

What a true from :renderOn does not say is that anything looks different. The parameter names it writes were read off a running game and are real — BaseColor, Base Texture, Normal Map and the rest of the list are on Mesh — but a write to a name a material does not carry is a silent no-op. A colour change has been watched landing on screen, on a build object rather than a pal (mesh-color-change, 2026-08-02: red → green → blue), so read a true as "the mesh was swapped and the material write ran" and check the result with your eyes the first time.

skills

skills is a list of ids that this definition declares. Declaring it does not put anything on a creature by itself, and nothing in the game fires the skills for you. Read the list back with :skillsOf() and run each one yourself through Skill.

local boss = Pal{
    id     = "BOSS_ChickenPal",
    name   = "Chicken Boss",
    skills = { "FlameThrower" },
}

-- fire everything this pal owns at the player
for _, id in ipairs(boss:skillsOf()) do
    Skill.get(id):activate(Player.character())
end

To put the declared list onto a creature standing in the world, so the game itself carries the skills, use :teachAll(actor) — see teachAll below.

icon

:iconOf() looks the id up in the game's own pal icon tables — DT_PalCharacterIconDataTable, then DT_PalCharacterIconDataTable_Common — and reads the Icon column. That column name is measured rather than guessed: a live read of all four icon tables in a loaded save answered 674 of 674 pal rows, 1183 of 1207 item rows, 567 of 571 building rows and 311 of 311 partner-skill rows. Pals and items key on Icon, buildings on SoftIcon, partner skills on TextureID_8_2B2F889C43EB586246BDB981B6462ACA. What comes back is always a /Game/... asset path as a plain string, never an engine object.

The id is resolved before the lookup, so "example:Boss" is looked up as the row spelling example_Boss and reaches a live row when one exists; an id that will not resolve is looked up literally. On any miss — no table, no row, no value — you get the icon you declared, and nil when you declared none.

The row match is case-sensitive, and both spellings of one creature are real on this build: SheepBall is the blueprint id dispatch keys on, Sheepball is the DataTable row this lookup answers to.

local pal = Pal{ id = "example:Boss", icon = "/Game/.../T_icon_example_boss" }
pal:iconOf()   -- the row "example_Boss" if PalSchema wrote one, else the declared icon

data

data is copied onto the definition untouched, and nothing in PalForge reads it. The handle has no accessor for it, so keep the table in a Lua local if you want it inside a handler.

local config = { reward = "Wood", amount = 5 }

local pal = Pal{
    id   = "ChickenPal",
    data = config,
    events = {
        onDeath = function(pal, ctx)
            Item.get(config.reward):give(config.amount)
        end,
    },
}

Events

This is where a pal gets its behaviour. Put your handlers under events. Each one is function(pal, ctx), where pal is this definition's handle — the same object Pal{ ... } returned, so :renderOn and the queries are right there — and ctx is a table describing what just happened. An event name that is not in the list below is an error at define time, not a silent no-op.

PalForge listens to the game, puts each event on a named channel, then finds the pal it happened to and calls your handler:

native hook -> event.emit -> channel -> dispatch -> resolve BP class name -> pal:onX(ctx)
HookChannelNative sourcectxState
onSpawnedpal.spawnedPalNPC:OnCompletedInitParam, and PalPlayerCharacter:OnCompleteInitializeParameterctx.actorLIVE, observed firing; armed at world.ready, never at load
onDamagedpal.damagedPalCharacter:OnDamageReactionctx.actorLIVE
onDeathpal.deathPalCharacter:OnDeadCharacterctx.actorLIVE
onCapturedpal.capturedPalCharacterParameterComponent:SetIsCapturedProcessing with started == truectx.actor, ctx.compLIVE
onTicktickno game event: core/event walks the live pals itselfctx.actor, ctx.count, ctx.nowLIVE, once per live pal every core.event.PAL_SCAN_MS — 3 s by default

ctx.actor is the creature in the world that the event happened to. For pal.captured the game fires on the pal's parameter component, so the actor is that component's owner and ctx.comp is the component itself. onDamaged fires on the pal that took the damage, never on the attacker — the game's own hook does not say who attacked.

pal.spawned has two sources because the obvious one carries nothing. PalCharacter:BroadcastOnCompleteInitializeParameter is the function that announces a character has finished its parameter init, it hooks cleanly, and it was measured silent in a live save while ten other channels reported. What a hook sees is what ProcessEvent runs, and a broadcaster is not it — so the two channels PalForge listens on are the delegate targets the broadcast invokes: PalNPC:OnCompletedInitParam, which every pal reaches on its own side, and PalPlayerCharacter:OnCompleteInitializeParameter, which fires for the characters the player subscribed to. Both were observed firing on 2026-07-26. The two are deduplicated per actor over a one-second window, so one pawn reaching both is one event.

A handler that reacts to a capture and gives the player something back:

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

Pal{
    id   = "SheepBall",
    name = "Sheepball",
    events = {
        onCaptured = function(pal, ctx)
            log.info("captured " .. pal:name() .. ": " .. tostring(ctx.actor))
            Item.get("PalSphere"):give(1)
        end,
    },
}

A handler that checks the creature is still valid before touching it:

Pal{
    id = "ChickenPal",
    events = {
        onDamaged = function(pal, ctx)
            local actor = ctx.actor
            if not (actor and actor.IsValid and actor:IsValid()) then return end
            Audio.get("AKE_Pal_Footstep"):play(actor)
        end,
    },
}

onTick runs on its own, roughly every three seconds, once for every live pal of that id. ctx.count counts the 500 ms heartbeats since the mod loaded, so it is an easy way to do something only now and then:

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

Pal{
    id = "ChickenPal",
    events = {
        onTick = function(pal, ctx)
            -- ctx.actor is one live Chicken Pal; this runs once per pal, per sweep
            if ctx.count % 12 == 0 then
                log.info("chicken still here: " .. tostring(ctx.actor))
            end
        end,
    },
}

-- slow the sweep down, or switch it off with 0
require("palforge.core.event").PAL_SCAN_MS = 5000

Handlers combine freely, and each one is optional:

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

Pal{
    id          = "ChickenPal",
    name        = "Chicken Pal",
    description = "logs every moment of its short life",
    events = {
        onSpawned  = function(pal, ctx) log.info("spawned "  .. tostring(ctx.actor)) end,
        onDamaged  = function(pal, ctx) log.info("damaged "  .. tostring(ctx.actor)) end,
        onDeath    = function(pal, ctx) log.info("died "     .. tostring(ctx.actor)) end,
        onCaptured = function(pal, ctx) log.info("captured " .. tostring(ctx.actor)) end,
    },
}

PalForge ships two ready-made pals in native/pals.luaChickenPal and SheepBall — with onCaptured, onDamaged and onDeath already wired to log lines. Catch or kill a wild one and those lines appear, which is the quickest way to check the chain works in your game.

What decides whether your handler runs

Four things.

  1. A pal is identified by its blueprint class name. Every creature in the game has a generated class called BP_<Id>_C. PalForge reads that name off the creature, turns BP_ChickenPal_C into ChickenPal, and looks that id up. A namespaced definition matches too when its resolved name equals the blueprint id, so Pal{ id = "example:Boss" } catches BP_example_Boss_C.
  2. Only registered ids get events. Pal{ ... } registers; Pal.get(id) does not. A vanilla pal you never defined resolves to nothing and the event is dropped.
  3. Nothing runs until the world is ready. The game hooks return immediately until five one-second polls in a row have found a valid PalPlayerCharacter. The onTick sweep waits for the same gate.
  4. Handlers run inside pcall, and a failure is logged. An error in your handler neither crashes the game nor stops the channel. PalForge catches it and writes the channel, the hook and the message to the log — pal.death -> onDeath handler failed: .... Everything after the failing line in that handler is still skipped.

Listening to every pal, not just your own

Your handlers only run for pals you defined. To hear about every pal in the world, including the vanilla ones, subscribe to the channel yourself:

local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("example")

local sub = event.on("pal.death", function(ctx)
    log.info("some pal died: " .. tostring(ctx.actor))
end)

-- later
sub:unsubscribe()

event.observable(name) gives you the same channel as an Rx observable, for operator chains. event.emit("pal.spawned", { actor = someActor }) pushes an event you made yourself through the whole chain. PalForge still works out the definition from ctx.actor's blueprint class name, so a hand-made ctx reaches your own subscribers, but reaches a definition's handler only when the actor really is a pal in the world. To run a handler on its own, use the event forwarders below.

Handle

Pal{ ... }, Pal.get and Pal.get_all all hand back a Pal.Handle. It carries .id, two actions, five event forwarders and five queries.

spawn

---@param arg Coord|table|nil
---@return boolean issued
pal:spawn(arg)

The argument is sorted out before anything happens: a table with .x or [1] in it is a coordinate, any other table is an options table, and no argument at all means default placement.

local pal = Pal.get("ChickenPal")

pal:spawn()                                   -- wild, near the player, level 1
pal:spawn(Player.coordinate())                -- at the player's exact position
pal:spawn(Player.coordinateOffset(300, 0, 0)) -- 3 m away on X
pal:spawn({ 12000, -4300, 800 })              -- array coordinate, x/y/z in centimetres
pal:spawn{ at = Player.coordinate(), level = 30 }
pal:spawn{ toPlayer = true, num = 3, level = 20 }   -- three, owned by the player
pal:spawn{ level = 45 }                             -- wild, near the player, level 45

The options table takes at (a coordinate), level (defaults to 1), toPlayer (anything truthy sends the pal to the player instead of the world) and num (defaults to 1, and is only read on the toPlayer route).

A spawn is not instant

The pal turns up roughly four to eight seconds after you call :spawn. That is the game's own pace, not a delay PalForge adds, and nothing can make it immediate.

So the boolean says the call was issued, and it cannot say more than that — your code has long since moved on by the time the creature exists. Do not look for the pawn on the next line, and do not treat a true as a pal standing somewhere.

To react to the pal, use the onSpawned handler, which runs when the creature actually arrives. If you must look yourself, look repeatedly over ten seconds or more.

false is the honest failure: the call was refused or never attempted at all — an empty id, a coordinate that is not a number, or no admin object to reach.

The arrival is reported in the log a few seconds later, with the elapsed time, so you can see what happened without instrumenting your own code:

[PalForge.spawn][info] spawn.pal ChickenPal: 1 new PalCharacter in the world 5.9 s after the call (look 15 of 20)

Spawning on a coordinate

A coordinate spawn is a spawn followed by a move. The game's own call ignores the position you ask for and drops the pal beside the player, so PalForge waits for the creature to arrive, picks out the one that was not there before, and teleports it onto your point.

It lands exactly there. The deferred pass reads the position back off the pal it moved and reports both that and the distance from what you asked for:

[PalForge.spawn][info] spawn.palAt: placed new pal at (-345296,263050,4153); it reads back (-345296,263050,4153), off by 0

What you see in game is the pal appearing next to you and then moving to the spot, a few seconds after the call. That is the shape of it, and there is no way to have the creature simply start at the coordinate.

Sending one to the player

:spawn{ toPlayer = true } hands the creature to the player's party or box instead of the world, and takes no admin object at all. Its true means the call was issued and stops there: nothing in Lua can look inside a party or a box, so there is no arrival to report and no log line follows.

The admin object

The world routes go through UPalCheatManager. On a client, the CheatManagerEnabler mod creates that object from PlayerController:ClientRestart; on a dedicated server that hook never fires, so nothing else creates one. core/spawn builds it itself when the session has none: it constructs the PalPlayerController's own CheatClass with StaticConstructObject — falling back to /Script/Pal.PalCheatManager and then /Script/Engine.CheatManager when that class is null — and attaches the result to the controller. The object stays there, so this happens once per session rather than once per spawn, and the log records the one time it runs. That is what gives a dedicated server the same route as a client.

The earliest failure is having no player controller at all: no world loaded, or not connected yet. Every route then returns false without reaching the game, and warns that no admin object was found and none could be built. Calling from world.ready avoids that window:

local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("example")

event.on("world.ready", function()
    if not Pal.get("ChickenPal"):spawn{ level = 10 } then
        log.warn("the spawn call was not issued")
    end
end)

renderOn

---@param actor any
---@return boolean ok
pal:renderOn(actor)

Attaches this pal's declared mesh to a live creature, once. The renderer guards against stacking it twice, so calling it again on the same actor is harmless. It returns false and does nothing when the actor is not valid, when the definition declares no mesh, or when that mesh has no model.

You do not normally call this. A definition that declares a mesh attaches it itself on pal.spawned. This is the manual route: a pawn PalForge did not spawn, a pawn you found through the onTick sweep, or re-applying after a detach. Calling it from onSpawned as well is harmless — the guard means the second call finds the mesh already there.

-- reach the sheepballs that were already standing there when the pack loaded
Pal{
    id   = "SheepBall",
    mesh = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall" },
    events = {
        onTick = function(pal, ctx)
            pal:renderOn(ctx.actor)   -- one-shot per pawn: the dressed ones cost nothing
        end,
    },
}

The whole declaration is handed to the renderer: kind, model, animClass, scale and offset come from the mesh, and the definition's material description then overwrites the color, texture, params and material it carries. animClass reaches the skeletal backend, which switches to animation-blueprint mode and binds that blueprint right after the swap. A swapped skeleton with nothing driving it does not animate and can vanish from view, so declare the ABP_*_C that belongs to the model you swapped in — not the one the creature was born with.

-- a chicken wearing a sheepball body: the anim blueprint travels with the model
Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/SheepBall/ABP_SheepBall.ABP_SheepBall_C",
    },
}

A true says the backend's setter ran — for the skeletal backend, that the model was set on the creature's own component and read back off it. What it ends up drawing is not something the call can report, and neither is whether the material write landed on a parameter the material carries.

teachAll

---@param actor any   # a live pal or player character
---@return integer taught, integer asked
pal:teachAll(actor)

Puts every skill this definition declares onto a live character, so the game itself carries them. :skillsOf() is what the author wrote; this is how that list reaches a real creature.

local Blaze = Pal{
    id     = "FoxMage",
    skills = { "FireBlast", "Legend" },
}

local taught, asked = Blaze:teachAll(somePalActor)
-- 2, 2 when both landed; 1, 2 when only one did

Each id routes on what the game knows it as, not on anything you declared: an id that is one of the game's own active moves is added to the creature's equipped moves, and any other id is added as a passive skill under that name. Skill has the full rule and the list of names.

Two numbers come back rather than a boolean, so a partial result stays visible. Skills go on in declared order, each write is checked by reading the character back, and a failure does not stop the rest — one unknown id should not cost a pal its other four moves. Both numbers zero means the definition declared no skills at all.

Queries

pal:skillsOf()      --> string[]   the declared skill ids, an empty table when none
pal:teachAll(actor) --> integer, integer   how many landed, how many were asked for
pal:mesh()          --> table?     the validated mesh declaration, nil when none
pal:iconOf()        --> string?    the DataTable row's /Game/... path, else the declared icon, else nil
pal:name()          --> string     the declared name, else the id
pal:description()   --> string?    the declared description, or nil
local log = require("palforge.utils.log").scope("example")

for _, pal in ipairs(Pal.get_all()) do
    local m = pal:mesh()
    log.info(string.format("%s (%s) mesh=%s skills=%d",
        pal:name(), pal.id, tostring(m and m.model), #pal:skillsOf()))
end

Event forwarders

:onSpawned(ctx), :onDamaged(ctx), :onDeath(ctx), :onCaptured(ctx) and :onTick(ctx) call the definition's handler straight away, with the ctx you pass. The game never goes through them — they are there so you can run a handler yourself, from a test or from another handler.

-- run the death handler now, with a hand-made ctx
Pal.get("ChickenPal"):onDeath({ actor = Player.character() })

Recipes

A pal that dresses itself and announces its arrival

content/party_chicken.lua
local log = require("palforge.utils.log").scope("example")

local Fanfare = Audio.se{
    id        = "AKE_CampLevelUp",
    soundId   = "AKE_CampLevelUp",
    soundPath = "/Game/Pal/Sound/Events/SE/UI/CampLevelUp/AKE_CampLevelUp.AKE_CampLevelUp",
}

local Chicken = Pal{
    id          = "ChickenPal",
    name        = "Party Chicken",
    description = "wears a custom skin and announces itself",
    mesh = Mesh{
        id        = "example:party_chicken",
        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",
    },
    color = { r = 1.0, g = 0.4, b = 0.1, a = 1.0 },
    events = {
        onSpawned = function(pal, ctx)
            -- the mesh is already on ctx.actor by the time this runs
            log.info("party chicken arrived: " .. tostring(ctx.actor))
            Fanfare:play(ctx.actor)
        end,
    },
}

Chicken:spawn(Player.coordinateOffset(300, 0, 0))

The declared mesh goes on before this handler runs, so a handler that wants to move, rename or re-tint the pawn finds the body already there. Whether the attach itself worked is in the mesh log, not in anything this handler can read.

Define the sound once, at the top of the file, and only play it from the handler. Defining inside a handler would register the sound again on every single spawn. When you have no local to hand, use Audio.get("AKE_CampLevelUp"):play(ctx.actor) instead.

A pal that drops loot on death

content/woolly_sheep.lua
local log = require("palforge.utils.log").scope("example")

local Wool = Item.get("Wool")
local Meat = Item.get("Meat")

Pal{
    id          = "SheepBall",
    name        = "Woolly Sheepball",
    description = "hands over wool and meat when it dies",
    events = {
        onDeath = function(pal, ctx)
            Wool:give(3)
            Meat:give(1)
            log.info(pal:name() .. " dropped its loot")
        end,
    },
}

Item.get(...):give(n) adds to the local player's inventory and reports what the inventory was measured to do, so check the boolean rather than assuming the drop landed. PalForge has no call for leaving loot on the ground where the pal died, so this is a reward the player receives directly. See Item for everything else you can do with items.

A pal that catches fire when it is hit

content/singe_chicken.lua
local log = require("palforge.utils.log").scope("example")

local Singe = Effect{
    id          = "example:Singe",
    name        = "Singe",
    description = "burns for six seconds after taking a hit",
    duration    = 6.0,
    interval    = 1.0,
    events = {
        onApply  = function(effect, target, ctx)
            log.info("singe applied by " .. tostring(ctx.source))
        end,
        onTick   = function(effect, target, ctx)
            log.info(string.format("singe tick at %.1fs", ctx.elapsed))
        end,
        onExpire = function(effect, target, ctx)
            log.info("singe over: " .. tostring(ctx.reason))
        end,
    },
}

Pal{
    id   = "ChickenPal",
    name = "Singed Chicken",
    events = {
        onDamaged = function(pal, ctx)
            if not ctx.actor then return end
            if Singe:isActive(ctx.actor) then return end
            Singe:apply(ctx.actor, { source = pal.id })
        end,
    },
}

An effect runs on the shared 500 ms heartbeat, so its onTick fires roughly every interval seconds until duration runs out. ctx.elapsed, ctx.stacks and, on expiry, ctx.reason come from that runtime; anything else in the table is what you passed to :apply. See Effect.

A recolored squad you can summon on demand

content/blue_squad.lua
local log = require("palforge.utils.log").scope("example")

local Squad = Pal{
    id          = "SheepBall",
    name        = "Blue Squad",
    description = "a recolored sheepball unit",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/SheepBall/ABP_SheepBall.ABP_SheepBall_C",
        scale     = 1.2,
    },
    material = {
        color = { r = 0.2, g = 0.5, b = 1.0, a = 1.0 },
    },
}

-- spread `n` of them in a line to the player's north-east
local function summonSquad(n, level)
    local at = Player.coordinate()
    if not at then
        log.warn("no player coordinate - not in a world yet")
        return false
    end
    for i = 1, n do
        Squad:spawn{ at = { x = at.x + i * 250, y = at.y + 250, z = at.z }, level = level }
    end
    return true
end

summonSquad(5, 20)

-- the party-owned variant: three of them straight into the box
Squad:spawn{ toPlayer = true, num = 3, level = 20 }

Each member takes its own spawn call, and each one wears the declared mesh as it arrives — no handler in the definition, because the attach rides pal.spawned on its own. The squad does not appear all at once or immediately: every member is a few seconds behind its own call, so they turn up over the following handful of seconds.

Limits

  • A spawn is not instant, and :spawn cannot tell you it worked. The pal arrives four to eight seconds later, so the boolean is about the call being issued. Arrival is in the log, and onSpawned is where a handler belongs.
  • Lua cannot add a brand-new creature. Pal{ id = ... } gives behaviour, visuals and metadata to a creature the game already has; creating the data row itself is PalSchema's job. An id with no matching BP_<Id>_C in the game never gets its handlers called, and :spawn has nothing to spawn.
  • onTick is a sweep, not a game event. core/event walks the live pals every core.event.PAL_SCAN_MS — 3000 ms by default — and calls onTick once per live creature. Set require("palforge.core.event").PAL_SCAN_MS = 5000 to slow it down, or 0 to switch it off. It is deliberately slower than the 500 ms heartbeat, because the walk touches every object in the game. When you need a faster or more exact timer, use require("palforge.core.event").every(2000, fn) — quantized to the 500 ms tick — or an Effect's interval.
  • onTick has no per-creature memory. pal is this definition's handle, and there is one handle for every creature of that id, so keep anything per-creature in a table of your own keyed by ctx.actor.
  • A broken onTick gets switched off. Five failures in a row and PalForge logs it and stops calling that definition's onTick for the rest of the session — for every creature of that id, not just the one that failed.
  • onSpawned fires for a genuinely NEW pal, and that is measured. pf_hook pal-spawned-fresh was run on 2026-08-02 and timestamped every firing against world.ready: 27 firings, 17 of them nowhere near a world load. So the event means what a pack wants it to mean. It also fires during the load storm, when every pal in range initialises at once, so a handler still has to be safe when it is called for a pawn it has already seen — key your own bookkeeping on ctx.actor rather than counting firings.
  • onSpawned's sources arm at world.ready, never at load. The initialise broadcast fires in the world-load pal-init storm, and a firing there once wedged the shared UE4SS hook dispatch and took the three confirmed hooks down with it. Late arming protects capture, damage and death, not just this channel.
  • Nothing runs before the world is ready. All four game hooks return immediately until five one-second polls in a row have found a valid PalPlayerCharacter, and the onTick sweep waits for the same gate.
  • A handler error ends that handler. PalForge calls your hook inside pcall and logs the failure with its channel and hook name, so the bug is visible in the log — but the rest of the handler does not run, and neither the channel nor the game notices.
  • A world spawn needs a player controller before it can even try. It needs PalCheatManager too, but nothing else has to create that first: when the session has none, core/spawn builds one from the controller's CheatClass and attaches it, so a dedicated server takes the same route as a client even though CheatManagerEnabler's ClientRestart hook never runs there. With no PalPlayerController to build it on, every world route returns false without reaching the game. The toPlayer route needs none of this.
  • A coordinate is a move after the spawn, not a spawn location. The game's own call ignores the position you asked for, so PalForge waits for the creature, picks out the new one and teleports it onto your point — where it lands exactly. That pass reports only to the log, and the line it writes carries the position read back off the pal.
  • A declared mesh is attached for you, on pal.spawned. :renderOn(actor) is the manual route for a pawn that did not come from that channel. core.mesh.ENABLED is a global kill switch that turns every attach into a no-op. The material fields reach every backend, but a parameter name the material does not carry is a silent no-op, so an attach is not a promise of a visible tint.
  • Pal.get does not register. Only Pal{ ... } puts a definition in the registry, so a handle from Pal.get can act but can never receive an event. Defining the same id twice replaces the earlier definition, and the registry logs it — naming both packs when the two definitions came from different ones.

Summary

  • Pal{ id = "ChickenPal", ... } gives one of the game's creatures new behaviour. id is the only field you have to fill in.
  • What happens to a pal goes under events: onSpawned, onDamaged, onDeath, onCaptured and onTick. Every one of them is live.
  • pal:spawn() puts a pal in the world, and the pal arrives four to eight seconds later. Pass a coordinate to place it exactly, or { toPlayer = true, num = 3 } to send them to the player. The boolean is about the call, so react in onSpawned.
  • A mesh changes what a pal looks like, and a declared one is attached for you on pal.spawned. pal:renderOn(actor) is the manual route.
  • Only ids you defined with Pal{ ... } get their handlers called; Pal.get gives you the actions without the events.
  • onTick runs about every three seconds per live pal, and has no memory of its own — key anything per-creature by ctx.actor.

Next, read Mesh to give your pal a body of its own.

On this page