Combat
The action-combat substrate. A character’s hitboxes and hurtboxes are Jolt sensor bodies
that ride the character’s skeleton bones; Jolt’s own collision detection reports the overlaps, and
the engine hands them to your game as Combat.onHit / onStay / onExit events. You author the
volumes’ shape and placement in Blender (bone-parented meshes in dedicated collections) — no hitbox
geometry in code — or build them by hand from Wren. Underneath, the same scene also exposes the
swept/overlap/pierce shape queries on Physics.
The engine detects; your game owns health. There is deliberately no HP, damage, or death type in
the engine. A hit carries an opaque payload — a plain number you set on the hitbox and read back
in the callback (typically an entity id, or the raw damage) — and the engine passes it through
untouched. What a hit means (how much damage, armor, elements, procs, i-frames, death) is 100% your
game’s Wren code. This keeps the engine health-model-agnostic and your game unconstrained.
The whole surface — Shape, Hitbox, Hurtbox, Hit, Combat, node.hitboxes/hurtboxes,
node.attachToBone, and the Physics.* combat queries — is a frozen contract. Detection is
governed by ADR 0069 (Jolt sensors), which supersedes ADR 0066’s shape-cast detection; the
“engine detects, game owns health” boundary and the opaque payload are unchanged from ADR 0066. See
the Blender Combat Demo, which loads Blender-authored volumes and lands
strikes through Combat.onHit.
How detection works
Section titled “How detection works”Each hit/hurt box is a kinematic Jolt sensor snapped onto its bone every frame (before the physics
step), on a dedicated combat collision layer that only pairs hit ↔ hurt (never world/dynamic bodies).
Jolt’s contact queue is the sole driver: the engine partitions the drain — pairs where both bodies are
combat volumes become combat events, everything else stays the generic
Physics.onContact* callbacks. A hitbox never strikes a hurtbox that shares its
owner.
Anti-tunnelling caveat. Jolt sensors have no continuous collision detection (CCD), so overlap is
sampled frame-by-frame. A realistic bone-driven swing (centimetres per frame) is caught reliably; a
hitbox teleported many units past a thin target in a single frame can miss. Swept anti-tunnel for
sensors is tracked as PLM-221. (The Physics.castShape query below is swept and remains the tool
for one-shot hitscan/lunge checks that must not tunnel.)
An immutable capsule/box/sphere, reused by the box factories and the Physics.* queries. (Convex-hull
volumes are built internally from Blender mesh vertices; a hand-authored Shape.convexHull factory is
deferred — see ADR 0069.)
Shape.capsule(radius, halfHeight)
Section titled “Shape.capsule(radius, halfHeight)”Returns: Shape — A Y-up capsule (halfHeight is half the cylinder segment; total height ≈ 2*halfHeight + 2*radius).
Shape.box(hx, hy, hz)
Section titled “Shape.box(hx, hy, hz)”Returns: Shape — A box with the given half-extents.
Shape.sphere(radius)
Section titled “Shape.sphere(radius)”Returns: Shape — A sphere.
Authoring volumes in Blender
Section titled “Authoring volumes in Blender”The intended workflow: author hit/hurt volumes as meshes in Blender, so shape and placement iterate
where the art does. The convention blend_load reads (ADR 0069):
- A hit/hurt volume is a mesh given a Blender Passive rigid body with “Animated” enabled (kinematic), bone-parented to the bone it rides.
- Placed in a dedicated collection —
Plume_HitboxesorPlume_Hurtboxes. The collection decides the role; the object name is the tag (e.g. an object namedsword→hitboxes["sword"]). - Shape comes from the rigid body (box / sphere / capsule / convex hull); the mesh AABB gives the half-extents, or the mesh verts the hull.
The Plume3D Blender add-on ships a Combat Volumes panel: select mesh(es), click Mark Hitbox /
Mark Hurtbox, and it sets the Passive+Animated rigid body and moves the object into the right
collection. On instantiate, each volume
becomes a bone-attached sensor Hitbox/Hurtbox (its Jolt rigid body is skipped — it’s a sensor, not a
physics collider) and surfaces on node.hitboxes / node.hurtboxes.
Hitbox
Section titled “Hitbox”A capsule/box/sphere/hull that strikes hurtboxes while active — a bone-attached Jolt sensor. Author
it in Blender (above) and reach it via node.hitboxes, or build one from Wren by attaching it to a
node (its scene-world anchor and the scene that detects it); optionally pass an
AnimController and a bone name so the box rides that bone in scene-world
(pass null / "" to ride the node directly). A hitbox never strikes a hurtbox that shares its owner,
and by default strikes each victim once per swing (see the dedup rules under independent /
reHitSeconds).
Hitbox.capsule(node, controller, bone, radius, halfHeight)
Section titled “Hitbox.capsule(node, controller, bone, radius, halfHeight)”Returns: Hitbox — A capsule hitbox bound to node (and, if given, controller’s bone).
Hitbox.box(node, controller, bone, hx, hy, hz)
Section titled “Hitbox.box(node, controller, bone, hx, hy, hz)”Returns: Hitbox — A box hitbox (half-extents).
Hitbox.sphere(node, controller, bone, radius)
Section titled “Hitbox.sphere(node, controller, bone, radius)”Returns: Hitbox — A sphere hitbox.
Fields
Section titled “Fields”layer/layer=(mask)(Num) — the bitmask of hurt-layers this hitbox strikes.0/0xFFFFFFFF= any.tag/tag=(name)(String) — a label carried into theHit(e.g."sword","kick"). Blender-authored volumes tag from the object name.owner/owner=(id)(Num) — an id shared across a combatant’s boxes; a hitbox never strikes its own owner.payload/payload=(n)(Num) — opaque; carried into theHituntouched (your damage / entity id).enabled/enabled=(flag)(Bool) — off = the sensor is deactivated and generates no contacts (a cheap game-driven active window).independent/independent=(flag)(Bool) — opt this hitbox out of the shared per-owner swing-dedup so it lands its own hit independent of the combatant’s other hitboxes (a multi-part boss where each limb should register). Defaultfalse.reHitSeconds/reHitSeconds=(n)(Num) — re-strike a persisting overlap everynseconds (an aura / damage-over-time).0(default) = once per swing until the overlap ends and re-begins.
hitbox.bindController(controller)
Section titled “hitbox.bindController(controller)”Bind an AnimController so the box rides its bone via the controller’s
pose. Blender-authored volumes are created before the character’s controller exists, so call this
after you build the controller — it links the box to the live skeleton. (Hand-built boxes pass the
controller to the factory instead.)
hitbox.bindWindow(controller, window)
Section titled “hitbox.bindWindow(controller, window)”Gate the hitbox’s active-state to a named animation hit-window on controller — it strikes only
while that window is open. Windows are authored as Blender pose-markers (a hitbox:swing …
hitbox:swing:end marker pair). Unbound, a hitbox is active whenever enabled.
Hurtbox
Section titled “Hurtbox”A capsule/box/sphere/hull that can be struck — also a bone-attached Jolt sensor. Same factory shape
as Hitbox (attach to a node, and optionally a controller + bone), authored in Blender or built from
Wren. Disabling it is i-frames (invulnerability) or death.
Hurtbox.capsule(node, controller, bone, radius, halfHeight)
Section titled “Hurtbox.capsule(node, controller, bone, radius, halfHeight)”Returns: Hurtbox
Hurtbox.box(node, controller, bone, hx, hy, hz)
Section titled “Hurtbox.box(node, controller, bone, hx, hy, hz)”Returns: Hurtbox
Hurtbox.sphere(node, controller, bone, radius)
Section titled “Hurtbox.sphere(node, controller, bone, radius)”Returns: Hurtbox
Fields
Section titled “Fields”layer/layer=(mask)(Num) — the layer(s) this hurtbox occupies (matched against a hitbox’s mask).tag/tag=(name)(String),owner/owner=(id)(Num),payload/payload=(n)(Num) — as onHitbox.enabled/enabled=(flag)(Bool) —falseis i-frames: the sensor is off, so it can’t be struck.
hurtbox.bindController(controller)
Section titled “hurtbox.bindController(controller)”As hitbox.bindController — link a Blender-authored hurtbox to the character’s AnimController so it
rides its bone.
The read-only event handed to a Combat.onHit / onStay / onExit closure.
attacker(Num) — the striking hitbox’sowner.victim(Num) — the struck hurtbox’sowner.hitboxTag(String) — the striking hitbox’stag.point(List) —[x, y, z]world contact point.normal(List) —[x, y, z]world contact normal.payload(Num) — opaque, carried from the hitbox untouched. Your HP/entity boundary.
Combat
Section titled “Combat”Three lifecycle events over a hit ↔ hurt overlap. Register a closure per scene per event; the engine
calls it at the tail of update() — you never call a resolve/tick yourself (a game-called tick would
re-enter the Wren VM, which is forbidden; the engine ticks combat for you, the same way it ticks
behaviour trees and cameras). Inside the closure you read the hit and mutate your own game state.
Health is yours.
Combat.onHit(scene, fn)
Section titled “Combat.onHit(scene, fn)”Register fn(hit) for a fresh strike — once per swing per attacker by default (a shared per-owner
ledger, reset when the attacker has no active hitbox), honouring hitbox.independent and reHitSeconds.
Fires on the first frame a hit/hurt pair is actively overlapping, whether that is a new contact or a box
activating onto an already-touching victim. Pass null to clear.
Combat.onStay(scene, fn)
Section titled “Combat.onStay(scene, fn)”Register fn(hit) for each frame a hit ↔ hurt pair keeps overlapping — auras, pressure, sustained
contact. Pass null to clear.
Combat.onExit(scene, fn)
Section titled “Combat.onExit(scene, fn)”Register fn(hit) for when a hit ↔ hurt overlap ends — release per-target state (e.g. a burn tick).
Pass null to clear. onExit fires on natural separation; if you destroy a box (drop your last
reference to it so it’s garbage-collected) while it’s still overlapping, no onExit is synthesized for
that box — so release any per-overlap state you opened when you remove a box. Disabling a box
(enabled = false, the i-frames / death pattern) keeps it and still fires onExit when contact ends.
import "engine" for Scene, Resource, Combat
var scene = Scene.new()var res = Resource.loadBlend("CombatVolumes.blend")var attacker = res.instantiate("Fighter", scene) // Blender-authored hit/hurt volumesvar target = res.instantiate("Fighter", scene)
// The GAME assigns identity: owner groups a combatant's boxes (a hitbox never strikes its own owner).for (hb in attacker.hitboxes.values) { hb.owner = 1; hb.payload = 25 } // opaque: THIS game reads it as damagefor (hu in attacker.hurtboxes.values) { hu.owner = 1 }for (hb in target.hitboxes.values) { hb.owner = 2 }for (hu in target.hurtboxes.values) { hu.owner = 2 }
var hp = 100 // health is a plain game field — no engine HP typeCombat.onHit(scene) { |hit| hp = hp - hit.payload // the engine detected the strike; the game decides what it costs if (hp <= 0) { for (hu in target.hurtboxes.values) hu.enabled = false // death: the corpse stops taking hits }}Node — combat volumes & bone sockets
Section titled “Node — combat volumes & bone sockets”Added to Node by the combat substrate.
node.hitboxes / node.hurtboxes
Section titled “node.hitboxes / node.hurtboxes”Returns: Map(tag → Hitbox) / Map(tag → Hurtbox) — the Blender-authored combat volumes on an
instantiated character, keyed by tag (the Blender object name). Empty when the .blend had none. Set
owner / payload and call bindController on the boxes to wire a character up after instantiate.
node.attachToBone(character, controller, bone)
Section titled “node.attachToBone(character, controller, bone)”Make this node follow character’s bone every frame (LateUpdate) — a stateful weapon/VFX socket,
engine-driven and Blender-authorable (bone-parent in Blender, or call this at runtime). Pass bone ""
(or a null controller) to track the character node’s world transform.
node.detachFromBone()
Section titled “node.detachFromBone()”Stop following (releases the socket).
Physics combat queries
Section titled “Physics combat queries”Swept / at-pose / piercing narrow-phase queries over the scene’s rigid bodies (ADR 0066, unchanged).
Each returns a List of RaycastHit (near→far), mapping each hit body back to its
Node. mask 0/0xFFFFFFFF = any gameplay layer. These are queries you call directly
(not the sensor event stream) — use castShape for a swept, tunnel-proof one-shot check.
Physics.castShape(scene, shape, fx, fy, fz, tx, ty, tz, mask)
Section titled “Physics.castShape(scene, shape, fx, fy, fz, tx, ty, tz, mask)”Returns: List of RaycastHit — every body a shape sweep from (fx,fy,fz) to (tx,ty,tz) crosses. The anti-tunnel primitive for fast one-shot attacks.
Physics.overlapShape(scene, shape, x, y, z, qx, qy, qz, qw, mask)
Section titled “Physics.overlapShape(scene, shape, x, y, z, qx, qy, qz, qw, mask)”Returns: List of RaycastHit — every body a shape overlaps at a pose (position + rotation quaternion x,y,z,w). True narrow-phase geometry (catches a large body whose center is outside the shape, unlike overlapSphere).
Physics.raycastAll(scene, ox, oy, oz, dx, dy, dz, maxDist, mask)
Section titled “Physics.raycastAll(scene, ox, oy, oz, dx, dy, dz, maxDist, mask)”Returns: List of RaycastHit — every body a ray from (ox,oy,oz) along (dx,dy,dz) up to maxDist pierces. The piercing-hitscan primitive.