Build a creature from its own rig and clips¶
A custom creature is not a standalone actor imported from a GLB. ContentTool clones a shipped
TacCharacterDef, installs your rig and clips, and rewrites the subset of animation actions it can
support. Read the clone model before
choosing a donor or source model.
The content engine builds the def. Your mod's DLL decides where that def enters the game: starting squad, faction deployment, an ability, or another campaign rule.
1. Prepare the project¶
Start with one binary glTF model:
The GLB must contain a skeleton, a skinned mesh below its rig root, and clips for at least walk,
idle, attack and death. A glTF file has no Unity Animator component: during import ContentTool
creates the Animator on the generated rig-root GameObject, then installs the donor's controller
there. Bone names and count are yours; ContentTool does not retarget them to the donor.
With exactly one .glb under Content\Models\, creature.model may be omitted and that file is
selected. With none or several and no model, the build is refused by name and nothing changes. An
explicit value is the file stem, matched case-insensitively; a stem that is not present is also
refused. The demo spells out "model": "cyborg_spider" even though it has only that one file.
Three terms used in this guide are practical measurements, not requirements from an assumed art tool. A bind pose is the skeleton's reference pose that attaches mesh vertices to bones before animation. Root motion is translation or rotation stored on the animation's root rather than only in moving limbs; ContentTool reads that displacement for movement and climbing. One tile means roughly one cell of Phoenix Point's tactical grid. The first bake prints a candidate scale that makes the measured model about that wide; it does not assume that one GLB unit equals one tile.
Declare the route with a complete initial manifest:
Close or save this file in your editor, then run:
The first run measures the model, prints its bone count, dimensions, one-tile scale candidate and
origin-to-lowest-vertex distance, and rewrites the creature block with every bakeable clip name.
It then refuses while required roles remain empty. That refusal is the intended scaffold workflow.
2. Map clips and measurements¶
Assign walk, idle, attack, and death. Optional roles are jump, reaction, ranged, and
climb. Clip names on the left must remain exactly as spelled in the GLB; the role on the right is
ContentTool's vocabulary.
ranged appears twice on purpose in the completed manifest below. Inside creature.clips it is a
clip role; the creature-level ranged key names a shipped WeaponDef to clone as the ranged
attack.
This is the complete working shape used by the spider demo:
{
"id": "morgott.demo.customcreature",
"bundle": "CustomCreature.bundle",
"scale": 0.008,
"play": "Spider_Idle",
"loop": "Spider_Idle, Spider_Walk",
"creature": {
"clips": {
"Spider_Walk": "walk",
"Spider_Idle": "idle",
"Spider_Idle_long": "",
"Spider_Damage": "reaction",
"Spider_Attack_1": "attack",
"Spider_Attack_2": "ranged",
"Spider_Death": "death"
},
"events": {
"walk": "SwarmerStep_EventDef 0.15, SwarmerStep_EventDef 0.65",
"attack": "ActionDo 0.4054, ShootShot 0.4865, ActionEnd 0.8378",
"ranged": "ActionDo 0.2286, ShootShot 0.5429, ActionEnd 0.9143",
"death": "Ragdoll 0.9"
},
"name": "Spider",
"model": "cyborg_spider",
"donor": "Swarmer_TacCharacterDef",
"ranged": "Crabman_Head_Spitter_WeaponDef",
"up": "0,1,0",
"lift": 2.1372,
"health": 40,
"will": 10,
"speed": 16,
"volume": 1,
"climbPitch": 90
}
}
With that already-complete manifest, a live bake printed these exact lines:
clip-names PASS "loop" names 2 clip(s) and "play" names 1 of the 7 this project bakes
creature-measure 'cyborg_spider': 49 bone(s), spans 120.435 x 64.237 x 105.578 file unit(s) about 0,29.979,-3.649; a tile is 1.0, so "scale": 0.008 makes it one tile across (this project declares 0.008). Its origin is 32.118 above its lowest vertex on +Y, which is "creature": { "lift" } if the model is centred rather than standing on its feet.
creature-clips 'cyborg_spider': 7 animation(s) in the file -> Spider_Walk, Spider_Idle, Spider_Idle_long, Spider_Damage, Spider_Attack_1, Spider_Attack_2, Spider_Death
creature-events PASS every blocking event the game waits for is declared
creature-roles PASS "clips" maps 6 of 7 discovered animation(s); every required role (walk, idle, attack, death) is mapped
ct_project: ALL PASS - <project>\Dist\CustomCreature.bundle
No creature-scaffold line appears because the manifest already contains every discovered clip.
The roles line says 6 of 7 because Spider_Idle_long is deliberately mapped to an empty role.
The printed 32.118 origin distance is a candidate lift for a model centred on its origin, not a
value to copy automatically. The demo's author judged the model in game and chose "lift": 2.1372;
you must make the same visual decision for your model.
Use the measurements as candidates when completing the file:
scaleis top-level uniform rig scale. The bake prints the value that makes the model about one tile across.upis the model's imported up axis. ContentTool rotates that vector to world up.liftis the author-chosen file-unit distance from origin down to the lowest vertex, applied after scale; the printed value assumes a centred model and need not be the value you choose.playselects the imported Animator's starting clip.loopmust include the mapped walk and idle.paceis tiles per second. Omit it for the shipped pace; set0to preserve the authored timing.speedis Action Points—how far the unit can move in a turn—not visual movement speed.healthzero keeps the donor's value. Set it deliberately; a donor with zero strength produces a creature that enters play dead.
Run ct_project MyCreature again. Fix the first refusal and repeat until the bundle bakes.
3. Choose the donor by capability¶
Swarmer_TacCharacterDef is the default because it is one tile and contains the required navigation,
animation, addons and melee structure. A facehugger has no body parts and cannot supply the required
melee weapon. A large donor can bring a multi-tile footprint or demolition machinery into a small
model.
Ask the live repository for candidates:
Choose based on component and combat behavior. Donor appearance and donor clip names do not help: the donor's clips are overridden. Its controller states, agent-related structure and abilities are the ceiling.
ContentTool separately selects a shipped one-tile reference unit for agent type, cursors and move
highlight. Its Walkable* nav area supplies the ground mask because area names are scoped to that
agent type. Do not treat “reference” as another manifest key; it is an internal, copied provenance
that prevents a donor's differently scoped nav mask from immobilizing the clone.
4. Author events at the real frames¶
Phoenix Point waits for named events. The attack role normally needs ActionDo, ShootShot, then
ActionEnd; death needs Ragdoll. Values are fractions of that clip's duration. Put ShootShot at
the actual contact/projectile frame and ActionEnd when the pose is ready to leave.
Missing event declarations are warnings during bake, but a live ability can wait ten seconds for each missing blocking event. That looks like a hung game. The event order in the string matters, and two waits should not share one timestamp.
See Phoenix Point animation contract for every slot, event and parameter.
5. Traversal¶
Why custom creatures used to stall¶
The pathfinder grants traversal by navmesh areas. An empty animation slot does not keep an actor off a link: the game can emit an L-shaped fallback using the run loop, wait up to five seconds at each point for a clip identity that never becomes current, fall back to high idle, and continue. That is the stepped wall motion and long pause the earlier implementation produced.
There was a second trap: nav-action field names and controller clip names are different vocabularies, and some nav fields are three-part sequences while others are single clips. Filling only sequence fields by a nav def's apparent name left real controller states untouched.
What ContentTool does now¶
ContentTool reads the rig's controller, synthesizes a start, looping pure-up climb and stop from the creature's own walk, and maps them into the controller and nav-action fields together. It covers 20 slots across roof drops, low-obstacle roof drops, ladder climbs, low-obstacle climbs, jump-over families and the ascent of one whole level. A nav area is added only when its real controller states and required clip parts are filled.
For the current one-tile Humanoid creature this produces:
That is a shipped soldier's mask plus JumpUpOneLevel, which the shipped Humanoid aliens
Crabman_NavigationDef and HumanoidGuardian_NavigationDef also carry. By comparison, shipped
ground-only units carry one
scoped ground area: Sentinel_Terror has WalkableHumanoid, Chiron_FireWorm has
WalkableMedMonster, Queen_Heavy has WalkableBigMonster, and PX_Scarab has
WalkableArmadillo. Shipped climbers add only the link areas they can actually traverse.
The engine derives climb height from baked root motion and repeats the loop for the variable remainder, so one set of clips crosses different obstacle heights.
climbPitch rotates the creature nose-up during synthesis. The spider uses 90: on a wall, its
ordinary leg cycle reads as climbing. 0 is the honest default for a biped, whose walk would look
wrong rotated onto its face. The general technique is: when the animation you need does not exist,
rotate the model so an animation you do have reads correctly.
If your GLB contains authored climbing art, map that clip to climb. It wins over synthesis and its
own root offset is used. The mapped clip must rise; bake refuses a non-rising climb rather than
shipping a sliding creature.
Measured result and ceiling¶
The spider crossed a 4.93-unit rooftop rise over 24.90 units of path in 3.94 seconds and a 5.00-unit drop in 2.68 seconds, ending at the ordered point with zero animation timeouts. Before the traversal fix, a shorter five-tile feature took 13.09 seconds and emitted three timeout lines; a shipped Fireworm took 3.25 seconds on that control route.
The synthesized walk cadence is still not authored climb art. All synthesized parts carry upward root motion even when the family is used for descent; the shared controller mapping makes that complete without stalls, but bespoke art is the visual upgrade.
Controller-state classification currently uses keywords in the controller's overridable clip names because shipped controllers cannot be fully enumerated offline. A future game controller with a new naming vocabulary can therefore be refused until ContentTool's family map is updated.
Climbing up one full level is available. It was refused for a season over a spelling: the state was
asked for as jump + level after the def field's name, while the shipped HumanoidAnimatorLOC
really carries MV_JumpUpOneFloor_Start_Placeholder, which says floor. The area was read the
same way, off a shipped human's list. The JumpUpOneLevel slot takes the same synthesized rising
clip the vault families take, so no extra clip is needed from you and nothing extra is mapped. Three
link segments carrying JumpUpOneLevel were crossed in 2.36 seconds with zero animation timeouts,
and a 4.93-unit ascent on another save took 3.93 seconds, also with none. One proof is still
outstanding: both measured crossings arrived on a downward order, so an arrival in the up direction
specifically over a jump-up link has not yet been demonstrated. Mount, Ram, JetJump and
FallNoSupport remain excluded because they are abilities or hazards rather than ordinary path
links.
6. Hitboxes and ranged attacks¶
By default ContentTool measures enabled renderers and creates a character-layer box collider plus a
camera collider for hover/click. Set hitBones to a comma-separated bone list for sphere colliders;
hitRadius controls their radius. Set colliders to off only when your own behavior creates and
verifies them. aim names a bone for the aim marker.
For a ranged creature, set ranged to a shipped WeaponDef and optionally set aiAction,
shootBone, and accuracy. ContentTool clones that weapon as a second attack. The ranged clip role
keeps its animation separate from melee. Test both attacks because their required events and target
frames can differ.
7. Build the def from your DLL¶
ppcontent.json cannot decide where a new actor belongs in the campaign, so a creature requires a
real DLL. Start from the complete project, references and ModMain skeleton, and
read its profile-wide Managed\ module warning
before adding references. Substitute MyCreature for MyMod in that project file.
CreatureBuild has two public members: public static TacCharacterDef Build(string modDir,
Action<string> log) and public static void JoinPlayerVehicle(TacCharacterDef def, string who).
Everything else on that class is internal.
Build registers and returns the cloned def. On failure it logs and returns null; it never throws.
JoinPlayerVehicle finds the current geoscape and takes the Phoenix faction's first vehicle. When
that vehicle does not already have a unit with the same TemplateDef, it creates a character from
def for the Phoenix faction using the current difficulty's starting-squad generation parameters,
then adds it. It reads the roster back and logs PASS or FAIL; who is only a label for that log.
It safely does nothing when no geoscape or vehicle exists.
This complete entry point uses the demo's starting-squad policy:
using System.Reflection;
using HarmonyLib;
using Morgott.ContentTool.Tactical;
using PhoenixPoint.Geoscape.Levels;
using PhoenixPoint.Geoscape.Levels.Factions;
using PhoenixPoint.Modding;
using PhoenixPoint.Tactical.Entities;
namespace YourName.MyCreature
{
public sealed class MyCreatureMain : ModMain
{
internal static TacCharacterDef Spider;
public override bool CanSafelyDisable => true;
public override void OnModEnabled()
{
Spider = CreatureBuild.Build(Instance.Entry.Directory, m => Logger.LogInfo(m));
((Harmony)HarmonyInstance).PatchAll(Assembly.GetExecutingAssembly());
}
}
[HarmonyPatch(typeof(GeoPhoenixFaction), nameof(GeoPhoenixFaction.CreateInitialSquad))]
internal static class SpiderJoinsNewCampaign
{
private static void Postfix()
{
CreatureBuild.JoinPlayerVehicle(MyCreatureMain.Spider, "CreateInitialSquad");
}
}
[HarmonyPatch(typeof(GeoscapeTutorial), "InitSquad")]
internal static class SpiderJoinsTutorialSquad
{
private static void Postfix()
{
CreatureBuild.JoinPlayerVehicle(MyCreatureMain.Spider, "Tutorial.InitSquad");
}
}
}
OnModEnabled first builds the def, then applies this assembly's two Harmony postfixes. The normal
new-campaign path calls GeoPhoenixFaction.CreateInitialSquad. The tutorial replaces that builder
with its private GeoscapeTutorial.InitSquad, so it is patched by name. Both postfixes are required
to put the creature in the player's vehicle in both campaign starts; changing
StartingSquadTemplate is insufficient because the tutorial reads it only for its length. For a
different campaign design, keep the Build call but replace these two injection seams with your own.
meta.json must name the DLL you built:
{
"ID": "yourname.mycreature",
"AssemblyName": "MyCreature.dll",
"Version": "1.0.0",
"Author": [
{ "Key": "English", "Value": "Your Name" }
],
"Name": [
{ "Key": "English", "Value": "My Creature" }
],
"Description": [
{ "Key": "English", "Value": "Adds a custom creature. Requires ContentTool." }
],
"Dependencies": [
"com.morgott.ContentTool"
]
}
ct_package finds the already-built DLL named by AssemblyName; it never compiles. Follow the
build-to-mod-folder and restart loop after every code change.
8. Test and ship¶
Test from a new campaign if your behavior adds the unit only during squad or storage creation. Verify all of these in a tactical mission:
- selection, hover and aim targeting;
- several ground moves, corners, climb, drop and low-obstacle links;
- melee and ranged actions, including their event timing;
- reaction, damage and death;
- save, reload and mod disable behavior appropriate to your injection seam.
Then run the final bake and package:
Install the packaged folder as a player and repeat the test after a cold restart. Ship the model only
when its license permits redistribution and include the required attribution in SOURCES.md.