Overview
Every creature (a Being actor) carries its anatomy on the actor itself, under system.body, derived by the Being-owned BodyLogic (exposed as being.body). The being’s body structure determines where blows land, how armor protects, which skills and attributes are impaired by injury, and whether a hit makes the target fumble a weapon or stumble. A being with an empty body structure (being.body.structure.parts.length === 0) is incorporeal — a spirit with no anatomy; check being.body.isIncorporeal.
A body structure has three tiers: body zones, the body parts within each zone, and the body locations within each part. A cross-cutting tag set of body roles ties parts to the skills and attributes they affect.
Storage is flat; the hierarchy is derived. The three tiers persist as three sibling arrays, each child naming its parent by shortcode; BodyStructure assembles them into the tree on every prepare. Flat storage keeps every edit a single whole-array write — a nested tree would force a by-index write into a sub-array, which Foundry rebuilds from a sparse map and corrupts (#247).
Where the data lives
The schema is the body SchemaField on the Being actor’s DataModel. See src/document/actor/foundry/BeingDataModel.ts:
system.body.structure
├── zones: BodyZone.Data[] // { shortcode, name, probWeight }
├── parts: BodyPart.Data[] // each names its zone via bodyZoneCode
└── locations: BodyLocation.Data[] // each names its part via bodyPartCode
At runtime, the data is rebuilt into domain objects in src/entity/body/:
BodyStructure— the root object; assembles the hierarchy and provides hit-location resolutionBodyZone— one anatomical region, owning a run of zone numbersBodyPart— one anatomical divisionBodyLocation— one hit location within a part
Every entity’s index is its slot in the flat array, so structure.parts[i].index === i and each updatePath is a plain two-segment path (system.body.structure.parts.4). A child’s position within its parent — position / position — is its relative order among the array elements sharing that parent, and is what drag-to-sort addresses.
A child whose parent code matches nothing is preserved in storage but left out of the hierarchy; read them from structure.orphanedParts / structure.orphanedLocations.
The BodyStructure and its zones/parts/locations are parented to the being’s BodyLogic (owned by BeingLogic); their persisted paths are system.body.structure.{zones,parts,locations}. Domain objects are reconstructed on every preparation cycle. Active effects may mutate them in-flight (e.g., adding protection modifiers), but only changes written through document.update() survive.
To persist, use the *Update() helpers on BodyStructure. They are symmetric across the three tiers, and each returns a complete-array payload:
| Tier | Add | Remove | Reorder / re-parent | Field edit |
|---|---|---|---|---|
| Zone | addZoneUpdate | removeZoneUpdate (cascades) | moveZoneUpdate | setZoneFieldsUpdate |
| Part | addPartUpdate | removePartUpdate (cascades) | movePartUpdate | setPartFieldsUpdate |
| Location | addLocationUpdate | removeLocationUpdate | moveLocationUpdate | setLocationFieldsUpdate |
Deletes cascade down the tree. Removing a zone also removes its parts and their locations; removing a part removes its locations. A child is never orphaned by a delete.
Renames re-point children. Because a child links to its parent by shortcode, changing a zone’s or part’s shortcode must be paired with repointPartsUpdate(old, new) / repointLocationsUpdate(old, new). The two payloads touch different arrays, so they merge by spread — see BodyZoneConfig / BodyPartConfig for the pattern.
Convenience wrappers stamp the parent code for you: addPartUpdate and addLocationUpdate.
Body parts
A body part is a primary anatomical division — Head, Torso, an arm, a leg, a wing. Persisted fields, from the defineSchema() of BeingDataModel.ts:
| Field | Type | Purpose |
|---|---|---|
shortcode | string | Stable identifier (e.g., headpart), unique body-wide. Named by its locations’ bodyPartCode. |
name | string | Display name (e.g., "Head"). Stored literally; not a localization key. |
roles | BodyRole[] | Functional tags the part fulfills — see Body Roles. |
probWeight | number | Selection weight within its zone: once the zone is rolled, its parts are drawn in proportion to this. Also the area an aimed strike spends its spread against. |
canHoldItem | boolean | Whether this part can grip an item at all — anatomy, not current state. The entity exposes it raw as canHoldItemBase; the same-named getter is derived. Arms typically true; others false. |
heldItemId | string | null | The ID of the item currently held, if any. |
favoredFlag | boolean | Marks the part as favored (off-hand vs. main-hand semantics). |
permanentImpairment | integer ≤ 0 | Manually-set permanent impairment for the part (0 = none). See Body-part impairment. |
permanentlyUnusable | boolean | Manually-set flag marking the part permanently unusable (withered / fully amputated), regardless of impairment tier. Implies isUnusable — see below. |
bodyZoneCode | string | Shortcode of the owning BodyZone. |
A convenience getter affectsMobility is true when the part has any of the vital, core, or locomotor roles.
Laterality and dominance
Which side of a body a part lies on is derived, never stored. A part is lateral when its shortcode begins with l or r and the mirrored shortcode also exists on that body: larmpart is left because rarmpart stands beside it. side exposes the answer; bodyPartSide is the rule.
Requiring the mirror twin is what makes the prefix safe. A bare “starts with l” test would read a central liverpart as left; with no riverpart on the body, the twin rule correctly gives it no side. Every one of the 237 body plans in the content packs satisfies this — no l*/r* part is unpaired — so the derivation is total, and it depends on the shortcode (a stable identifier) rather than the display name (prose, and localizable).
A being’s dominant side is a different question, and comes from its characteristics rather than from anything on a limb — dominantSide reads the ldmnc / rdmnc Trauma items:
| Left Dominance | Right Dominance | Dominant side |
|---|---|---|
| yes | no | left |
| no | yes | right |
| yes | yes | none — ambidextrous |
| no | no | none — no side is favored |
This is the single home for the dominance question wherever a favored side matters; the off-hand impact reduction (isOffHandGrip) is only its first caller. A grip is off-hand only when every limb holding the item is on the non-dominant side, so a two-handed grip never is, and a being with no dominant side never grips off-hand at all.
The persisted
favoredFlagon a body part is not this mechanism and is read nowhere — dominance is a property of the being, not of a limb.
Body locations
A body location is a specific hit point within a part — Skull, Thorax, Right Elbow. Persisted fields, also from the defineSchema() of BeingDataModel.ts:
| Field | Type | Purpose |
|---|---|---|
shortcode | string | Stable identifier (e.g., skullloc, relbloc), unique body-wide, not merely within its part. |
bodyPartCode | string | Shortcode of the owning BodyPart. |
name | string | Display name (e.g., "Skull"). Stored literally. |
probWeight | integer | Relative weight for random hit selection within the parent part. |
shockValue | integer | Inherent shock inflicted by an injury at this location, regardless of severity. |
bleedingSusceptibility | tier | none / low / medium / high. Combined with injury severity and weapon aspect by BleedingDefaults to decide whether a wound bleeds. |
amputability | tier | none / low / medium / high. Drives the Strength-test modifier when a G5 Edge injury would amputate; see AmputationDefaults. none means amputation is disallowed at this location. |
protectionBase | {blunt, edged, piercing, fire} | Natural armor values per ImpactAspect. May be negative — see below. |
Both tiers map to the rulebook’s shaded markers (none/white/grey/black for bleeding; same for amputability).
Negative natural armor
protectionBase is unbounded below. A hide softer than bare human skin — a
crow’s is −6 blunt / −8 piercing, a cat’s −3/−5 — carries a negative
value, and resolveInjury lets it raise the effective impact
(impact − protection, so a 3-impact blow lands as 9 on the crow) rather than
clamping it away. Armor reduction still bottoms out at the location’s own
floor, min(armorValue, 0): it can strip a hauberk to nothing, but it cannot
make an already-vulnerable hide worse.
This is a separate axis from body scale, which rescales the thresholds an impact is judged against. Scale answers “how much damage does this body absorb before a wound is Serious”; negative armor answers “how little does its hide stop.” A small creature typically carries both.
Body roles
A cross-cutting tag set. The four values, defined in src/utils/constants.ts under BODY_ROLE:
| Role | Anatomical examples |
|---|---|
vital | Brain, sensory organs, vital nerve clusters. Head for vertebrates; cephalothorax for arachnids; ganglia for invertebrates. |
core | Power and balance. Torso for humans; abdomen for insects; mantle for cephalopods; body segments for snakes. |
manipulator | Fine work and intentional force. Arms, paws, tentacles, trunks; jaws used as bite-weapons. |
locomotor | Movement. Legs, wings, fins; tentacles used for swimming. |
A part may carry multiple roles. A wolf’s foreleg might be [locomotor, manipulator]; its head [vital, manipulator] (bite attacks).
What roles drive:
- Skill / attribute impairment. Skills and attributes carry an
impairedByRoles: BodyRole[]field. When a body part takes an injury, every skill and attribute whoseimpairedByRolesintersects the part’srolesis impaired. Mental attributes leave the list empty; physical ones list the relevant roles. See src/document/item/foundry/SkillDataModel.ts and AttributeDataModel.ts. - Mobility impairment.
BodyPart.affectsMobilityreturnstruewhen the part has any ofvital,core, orlocomotor. - Mishap checks (fumble / stumble) on injury severity:
vitalSerious → fumble + stumble check; Grievous → both autocoreSerious → fumble + stumble check; Grievous → both automanipulatorSerious → fumble check; Grievous → auto fumblelocomotorSerious → stumble check; Grievous → auto stumble
Resolving a flagged mishap — the keep-control test
A flagged fumble or stumble (whether from the injury-severity checks above or a
combat critical failure — the mishaps: Set<string> on an attack/defense result,
see Combat Resolution Pipeline) is resolved by a
keep-control test on the affected being (#851 / #852):
- Stumble — a “keep your footing” test rolling the better of the being’s Agility attribute and Acrobatics skill. A failure falls prone.
- Fumble — a “keep your grip” test rolling the better of the being’s Dexterity attribute and Legerdemain skill. A failure drops the held item.
Selection is by effective mastery level, with ties to the trained skill; either
ability alone is used when the other is absent, and a being with neither warns and
does not roll. Both BeingLogic.stumbleTest / fumbleTest are offered, never
auto-performed — the mishap surfaces on the attack card as a prompt the target’s
controlling player accepts. Each is an ordinary successTest whose only bespoke
part is a keepControlTable result mapping passed in scope — see the
graded-test-as-data recipe
and src/document/actor/logic/keep-control.ts.
Body-part impairment
Impairment is the penalty to any use of a body part — it grows with wounds and
eases as they heal ({@link bodyPartImpairment}, src/entity/body/impairment.ts).
Impairment is the worst (most negative) of {the part’s permanent impairment,
each current injury} — never additive:
| Source | Impairment |
|---|---|
Grievous injury (G4/G5, level ≥ 4) | unusable (no number) |
Serious injury (S2/S3, level 2–3) | −10 |
Minor injury (M1) with healing rate ≤ 5 | −5 |
permanentImpairment field | its value (any ≤ 0) |
| none / a fast-healing minor injury | 0 |
The magnitude tiers the part — NONE (0) / MINOR (−5) / SERIOUS (−10) / GRIEVOUS
(≤ −11). A grievous injury adds no number but makes the part unusable;
permanent impairment tiers the part (a −20 arm is GRIEVOUS) but never unuses it —
only a grievous injury or the manually-set permanentlyUnusable flag (a withered
or fully-amputated limb) does. The derivation is pure and Foundry-free; the
Being-sheet header grid colors each part by status (none = white, MINOR = yellow,
SERIOUS/GRIEVOUS = blue, unusable = black).
Immobilized, unusable, and the ability to hold
A limb being out of the fight and a limb being unable to grip are two different states (#1269) — conflating them would make a constricting hold disarm its victim. BodyPart models them as one settable switch plus two derivations, all Logic-only: nothing here is persisted, and every value is rebuilt from the persisted schema on each preparation cycle.
| Source | Sets | Follows by derivation |
|---|---|---|
| Immobilized trauma | immobilized | nothing — the grip is retained |
| Grievous injury | isUnusable | immobilized, and the loss of canHoldItem |
permanentlyUnusable (persisted) | isUnusable | the same, permanently |
isUnusable = permanentlyUnusable || <set during the lifecycle>
immobilized = isUnusable || <set during the lifecycle>
canHoldItem = canHoldItemBase && !isUnusable
So isUnusable is the single switch for “this
limb is out of action”, and immobilized is the
weaker state a hold produces on its own. Because both are settable Logic
properties rather than schema fields, an Active Effect keyed
mod:logic.<property> can drive them (SoHL effect keys target Logic properties,
not schema paths) — though addressing them on the actor would need a Being/body
effect-key namespace that does not exist yet.
Who sets what, and when:
- The Immobilized trauma (IMMOBILIZED_CODE, a
physcond/impedimentTrauma) setsimmobilizedon the part owning itsbodyLocationCode, during the trauma’s owninitialize()— which the actor’sinitialize(), where the body is built, precedes. The flag lives only on the rebuilt part, so deleting the trauma releases the limb with no lifecycle to unwind. A grappling hold and a binding spell impart the same condition; this is why a per-limb magical effect needs no part-addressable Active Effect. - A grievous injury sets
isUnusableinBeingLogic.finalize(deriveBodyPartUsability), once the trauma items have settled their levels. One switch, and immobilization plus the loss of the grip follow. - The drop is not a derivation.
canHoldItemgoingfalsedoes not clearheldItemId— something has to write that. It is a one-time write at the injury event (dropHeldItemAt, called fromcreateTraumaFromInjury), not a lifecycle side effect: an event happens once, so re-preparation never re-drops and an item the player picks back up stays put.
Readers of canHoldItem — the held-item
dropdowns on the Being sheet, BodyStructure.limbsHolding behind strike-mode
gating — therefore see the effective answer. Anything that writes the capability
back must read the persisted value instead, or the flag would be clobbered while
the limb is disabled: the body-part editor
(BodyPartConfig.ts) edits the raw
BodyPart.Data and so is unaffected; a consumer holding an entity reads
canHoldItemBase.
Impairment reaches test resolution through a part’s roles (#568). A skill or
attribute declares the body-part roles it depends on in its impairedByRoles, and
the being projects its injured parts onto two role views: being.unusableRoles()
(roles of every unusable part) and being.impairedRolePenalties() (each
still-usable-but-impaired role → its worst −5/−10 penalty; the two never overlap,
since an unusable part contributes no number). In
successTest a test whose
impairedByRoles intersects an unusable role is forced to a Critical Failure
(the pure testAutoCriticallyFails); otherwise the worst matching −5/−10
penalty is folded into its effective mastery level (the pure
testImpairmentPenalty). Both are strict no-ops for a test with no
impairedByRoles or an actor with no impaired parts.
Weapon strike modes gate on the specific held limb, not a role (#628). A
strike mode names its required limbs by count (minParts), so gating on the
being-wide role set would be too coarse — an unusable off-hand you are not gripping
with must not fail the roll. Instead GearLogic.heldLimbImpairments resolves the
part(s) actually holding the weapon (via heldBy) and scores each through
being.bodyPartImpairments(parts) (the per-part twin of the role views). In the
same successTest seam, an unusable held limb forces a Critical Failure and an
impaired-but-usable one folds its worst −5/−10 into the mastery level — via the pure
requiredPartsAutoCriticallyFail / requiredPartsImpairmentPenalty,
the per-part counterparts of the role helpers. When a test is gated on both a role
and a held limb, the worst of the two applies, never their sum. Natural-weapon
(combat-technique) modes still gate through their skill’s impairedByRoles; a
per-part link from a natural weapon to its body part remains a follow-up.
Impairment drives being health (deriveHealth,
src/document/actor/logic/health.ts) — a banded assessment, not a point pool
(SoHL has no hit points). Each impaired part caps overall health by (its state,
whether the part is critical — holds a VITAL or CORE role — and how many
parts share that state); the physical health is the minimum cap across all
parts, mapped to a band (Excellent … Dead). health.max is always 100; a living
being never falls below 1. Stun/fatigue/fear/shock ceilings compose later as
additional minimums.
Body scale (per-creature injury scaling)
Impact is an absolute quantity, but an injury level is relative to the
body absorbing it — the same 3-point dagger is trivial to a cow and grievous to a
cat. The being’s body carries a bodyScaleBase factor (1.0 = a baseline human;
larger = bigger/tougher body), exposed as the clamped bodyScale ValueModifier
on BodyLogic (being.body.bodyScale). Seed it
from ((typical species STR) / 11) ^ 0.65 — 11 being the human strength the
master table is calibrated for, so Strength 11 maps to exactly 1.0.
The exponent compresses the ends. A linear STR / 11 spread the bestiary from
0.18 to 5.45 and put most of that range in the tails; at 0.65 it centres on
1.30 with two standard deviations covering roughly 0.3 to 2.3, and a scale
of 3 sits at about +3 sd — reached by the largest dragon at 3.01 and by nothing
else. The low end barely moves (a wolf goes 0.91 to 0.94); the compression is
felt where it should be, at the top.
A creature may of course be given a scale out of line with its Strength
deliberately — bodyScaleBase is authored, not computed — but the curve is what
an ordinary creature is seeded from.
The master thresholds (BASE_INJURY_THRESHOLDS, [1, 5, 10, 15, 20]) are never
mutated; each creature derives its own injuryTable = master × bodyScale in
BodyLogic, exposed as being.body.injuryTable and on the body structure.
injuryLevelFromImpact counts how many of that creature’s
thresholds an impact reaches, so an impact below the smallest (scaled) threshold
leaves no wound — a 2-impact blow is S2 on a bodyScale 0.27 cat but is ignored
by a bodyScale 2.9 cow (which needs ≥ 3 for even M1). Everything the level
feeds — Shock Index, bleeding, amputation, stumble/fumble, health — becomes
size-correct at the source, with no changes to those subsystems. An Active Effect
on system.body.bodyScaleBase (shrink/enlarge) re-scales the table within the same
prepare cycle.
The scale is clamped to 0.01 – 3
bodyScale is floored at MIN_BODY_SCALE (0.01) and capped at MAX_BODY_SCALE
(3), including any Active-Effect delta, so an enlarge cannot lift a being past
the ceiling.
The cap exists because impact and the thresholds grow at different rates
(#1242). Impact tracks Strength at about STR ÷ 2, while an unbounded scale
grows the top threshold at 20 × STR ÷ 11 — roughly STR × 1.8, some 3.6 times
faster. Past a scale of about 3 the thresholds outrun every impact the system can
produce: an Old Dragon at its raw 5.45 would need an effective 109 for a Grievous
injury, where the largest impact in the game is its own 33-point bite, so nothing
— not another dragon, not a trebuchet — could wound it at all.
At the cap a body has thresholds [3, 15, 30, 45, 60], which keeps the top of
the range hard but reachable. Natural armour, not body scale, is what makes a
dragon proof against swords: a hand weapon maxes at 15 impact and cannot pass a
dragon’s 28-point hide whatever the thresholds say, while a siege engine or a
spell that does get through now wounds in proportion.
Seeding bodyScaleBase from Strength above 33 is therefore harmless but
inert — the creature is already at the ceiling.
Body zones
A body zone is the broadest anatomical division and the first stage of hit determination. Persisted fields:
| Field | Type | Purpose |
|---|---|---|
shortcode | string | Stable identifier (e.g., armszone), unique body-wide. Named by its parts’ bodyZoneCode. |
name | string | Display name (e.g., "Arms"). Stored literally; not a localization key. |
probWeight | integer | How many zone numbers this zone claims. 0 makes it unrollable. |
Zone numbers are allocated in persisted zone order, each zone taking a contiguous run sized by its weight. A body whose zones weigh 1 / 4 / 3 / 4 hands out 1, 2–5, 6–8, 9–12.
Across the whole body the numbers are therefore contiguous, unique, gap-free, and monotonically increasing by 1 from 1 — an invariant the suite asserts directly. A zero-weight zone claims no numbers and does not interrupt the run. Two consequences follow:
- maxZoneNumber is the
Nof that1..Nrun (12 in the example above). It is the same number as the sum of every zone’s weight, by construction. - getZoneByNumber resolves any integer in
1..Nto exactly one zone, and returnsundefinedfor anything else —0or below, aboveN, or non-integer. zoneNumbers exposes a single zone’s run.
Zone order therefore matters: moveZoneUpdate re-allocates every subsequent zone’s numbers. probWeight is deliberately not wrapped in a ValueModifier — the runs are positional, so an active effect that moved one zone’s weight mid-cycle would desync every zone above it.
Aimed-strike drift
Zones also supply the neighbourhood the old part-adjacency graph used to provide (#780). getNeighborParts returns the nearest ring of candidates:
- The part’s own zone siblings (a left arm drifts to the right arm first).
- Failing that, parts of the nearest zones by index distance, widening one step at a time in both directions at once.
Only the closest non-empty ring is returned, so a strike drifts exactly one step per iteration. This drives getRandomPart when a target is supplied:
- Roll
1..spread. - If the roll ≤ the current target part’s
probWeight, that part is hit. - Otherwise, reduce remaining spread by
probWeightand drift to a random part of the nearest ring. Repeat. - If no unvisited neighbour remains, hit the current part.
Hit-location pipeline
BodyStructure.getRandomLocation(target?) is the canonical entry point during attack resolution:
getRandomPart(target?)selects a part. Unaimed, this rolls a zone weighted by itsprobWeight(getRandomOccupiedZone), then draws a part inside it weighted by the part’sprobWeight. Aimed, it runs the drift algorithm above.- The selected part’s
getRandomLocation()picks a location within it, weighted by each location’sprobWeight.
The same rule applies at all three tiers: an entry is drawn with probability probWeight / (sum of its siblings' probWeight). So for an unaimed strike,
P(location) = zone.probWeight / sum(all zones' probWeight)
x part.probWeight / sum(that zone's parts' probWeight)
x loc.probWeight / sum(that part's locations' probWeight)
A zone that carries weight but holds no parts is excluded from the roll rather than falling through to a body-wide draw — otherwise its share would leak out and skew every other zone’s true frequency. getRandomZone still reports it, since it owns real zone numbers and the displayed table must say so.
Two selectors deliberately sit outside this model: the drift algorithm above (getRandomPart(target), a general aimed-selection utility), and getRandomPartByRole, which is a flat weighted draw over every role-matching part body-wide — it answers “any vital part,” not “where did the blow land.”
Zone-Die aiming (Resolve Injury)
The Resolve Injury action determines its hit location by Zone-Number aiming with a Zone Die via aimZone, not the drift algorithm:
- Roll the zone die uniformly in
1..zoneDie. Hit ZN = (targetZoneNumber − 1) + result.- Look up the zone owning
Hit ZN(getZoneByNumber). AHit ZNabove maxZoneNumber (or a zone with no hittable part) is a miss — no location. - Otherwise draw a weighted part in that zone, then a weighted location within that part.
aimZone returns the full trace (targetZoneNumber, zoneDie, zoneDieResult, hitZoneNumber, zone, location, isMiss) so the result card can echo how the location was determined. targetZoneNumber defaults to 1 and an unaimed strike uses zoneDie = maxZoneNumber, which reproduces the whole-body weighted distribution above.
For the broader resolution flow (rolls → wound calculation → effects), see Combat Resolution Pipeline.
Localization
A part’s or location’s display name is the literal name field on the part or
location itself, baked into the compendium JSON in the active language
("name": "Skull"). That is the only name the system reads at runtime, and the only
one to set when authoring a body structure.
There is no parallel per-shortcode key mechanism. A SOHL.BodyPart.* /
SOHL.BodyLocation.* key set once existed alongside the names, but nothing ever read
it — 169 keys with zero call sites — and it was removed in #1349. Do not add keys for a
new part or location; set its name.
The SOHL.BodyPart.FIELDS.*, SOHL.BodyLocation.FIELDS.* and SOHL.BodyZone.FIELDS.*
keys that remain are a different thing: they label the config apps’ form fields
(Probability Weight, Causes Stumble, …), not the parts themselves.
Reference: Human body
The Human body structure is the reference anatomy shipped today — carried on the “Basic Folk” being’s system.body.structure (authored in assets/content/Characters/Basic_Folk.md). Its structure:
| Part shortcode | Name | Roles | probWeight | Can hold |
|---|---|---|---|---|
headpart | Head | vital | 1 | no |
torsopart | Torso | core | 4 | no |
larmpart | Left Arm | manipulator | 2 | yes |
rarmpart | Right Arm | manipulator | 2 | yes |
llegpart | Left Leg | locomotor | 3 | no |
rlegpart | Right Leg | locomotor | 3 | no |
Locations:
| Part | Location shortcodes |
|---|---|
headpart | skullloc, leyeloc, reyeloc, noseloc, lcheekloc, rcheekloc, learloc, rearloc, mouthloc, jawloc, neckloc |
torsopart | thrxloc, abdmnloc, plvisloc |
larmpart | lshldloc, lupaloc, lelbloc, lfraloc, lhandloc |
rarmpart | rshldloc, rupaloc, relbloc, rfraloc, rhandloc |
llegpart | lthghloc, lkneeloc, lcalfloc, lfootloc |
rlegpart | rthghloc, rkneeloc, rcalfloc, rfootloc |
Zones (in order, with the numbers each claims):
| Zone shortcode | Name | probWeight | Zone numbers | Parts |
|---|---|---|---|---|
headzone | Head | 1 | 1 | headpart |
armszone | Arms | 4 | 2–5 | rarmpart, larmpart |
torsozone | Torso | 4 | 6–9 | torsopart |
legszone | Legs | 6 | 10–15 | rlegpart, llegpart |
Body plans shipped in the animals pack
Suffix every zone shortcode with zone, every part shortcode with part, and every location shortcode with loc. Use l* / r* prefixes for left/right pairs. Part and location shortcodes must be unique body-wide.
Fifteen body plans are authored across the sohl.actors pack. Each mirrors the shape of a printed hit-location table where one exists and extrapolates the same construction where none does; zone weights scale with the creature’s size band, while part and location weights are the plan’s own.
| Plan | Zones | Example creatures |
|---|---|---|
ungulate | Head · Forelegs · Torso · Hindquarters | bovine, horse, stag, rhino |
carnivore | Head · Forelegs · Torso · Hindquarters | bear, lion, wolf, crocodile |
smallQuadruped | Forequarters · Torso · Hindquarters | cat, dog, badger, lizard |
anthropoid | Head · Arms · Torso · Legs | ape, monkey (and every Being) |
smallAvian | Head · Body · Hindquarters | crow, raven, bat |
largeAvian | Head · Wing · Body · Wing · Hindquarters | eagle, condor, roc |
biped | Head · Body · Hindquarters | ostrich, raptor-lizards |
drake | Head · Wings · Forelegs · Torso · Hindquarters | forest and mountain drakes |
serpentine | Head · Forebody · Hindbody | snake, centipede, wurm |
proboscidean | Head · Trunk · Forelegs · Torso · Hindquarters | elephant |
arachnid | Cephalothorax · Abdomen · Legs | spider, scorpion |
insect | Head · Thorax · Abdomen | ant, wasp, beetle |
aquatic | Head · Body · Tail | shark, orca, seal |
chelonian | Head · Shell · Limbs | tortoise, turtle |
cephalopod | Mantle · Head · Arms | octopus |
An ape or monkey uses the human plan unchanged — the same six parts and thirty-two hit locations a Being carries — over a zone run scaled to its size, so a monkey’s zone numbers run 1–6 where a person’s run 1–15.
Adding a body part to a being
Use BodyStructure.addPartUpdate(partData) to build the update payload:
const zone = structure.getZoneByCode("tailzone");
// `BodyZone.addPartUpdate` stamps `bodyZoneCode` for you.
await beingActor.update(
zone.addPartUpdate({
shortcode: "tailpart",
name: "Tail",
bodyZoneCode: "tailzone",
roles: ["locomotor"],
favoredFlag: false,
canHoldItem: false,
heldItemId: null,
probWeight: 1,
}),
);
Its hit locations are added separately, against the flat locations array — again with the parent code stamped for you:
const part = beingActor.logic.body.structure.getPartByCode("tailpart");
await beingActor.update(
part.addLocationUpdate(blankBodyLocation("Tail Tip", "tailtiploc")),
);
Add the zone first if it does not exist (structure.addZoneUpdate(blankBodyZone("Tail", "tailzone"))); a part whose bodyZoneCode names no zone is stored but stays out of the hierarchy.
The part’s and locations’ display names are the literal name arguments above — there are no localization keys to add (see Localization).
From the Being sheet
An owner can also author the anatomy directly on the Combat tab’s Body Structure section — no macro needed:
- Add — the section header carries a + Add control that creates a body part; each body-part header carries a + Add that creates a hit location under it. Both prompt for a name and a unique shortcode.
- Edit — each part header and location row has a ⋮ menu whose Edit
opens the
BodyPartConfig/BodyLocationConfigeditor for that entry, which auto-saves each field change (roles, protection, bleeding / amputability tiers, and so on). - Delete — the same ⋮ menu’s Delete removes the entry after confirmation. Deleting a part is refused while it still owns hit locations — remove those first.
- Reorder — parts and locations reorder, and locations move between parts, by drag-and-drop.
Every one of these writes rebuilds the whole parts array through the
BodyStructure update builders (addPartUpdate / removePartUpdate /
movePartUpdate / moveLocationUpdate / setPartFieldsUpdate) — never a
by-index write (see the array-corruption note above). All controls are
owner-gated; a non-owner sees the read-only tree.