Enemies
The key words MUST, MUST NOT, and MAY below are used as in RFC 2119.
Every enemy is one node class — EnemyBase (a CharacterBody3D) — driven by an EnemyData resource. Stats, model, element, and spawn locations live in the data; the AI, animation, status, and SFX logic live in the shared base. New enemies SHOULD be a new EnemyData resource with no new script.
Roster & data
The runtime roster is the set of EnemyData resources under data/enemies/*.tres, loaded at boot by EnemyRegistry and keyed by id. Each entry carries:
| Field | Meaning |
|---|---|
id / name | lookup key and display name |
element | Native / Beast / Machine / Dark — drives damage attribution |
locations | area ids this enemy spawns in |
is_rare / is_boss | roster flags |
model_id / animation_model_id | GLB under assets/enemies/<id>/; rares MAY borrow a base model's animations |
| combat stats | hp_base, defense_base, evasion_base, move_speed, attack_range, detection_range, attack_cooldown, exp_reward, meseta_min/max |
Spawning
The canonical path is the field controller's _spawn_enemy(enemy_id): it resolves the id through EnemyRegistry, instantiates EnemyBase, assigns enemy_data, and adds it to the map. The base then loads its own model, hurtbox, navigation, and target on _ready. Defeated enemies emit died; the field controller (not the enemy) owns drops.
Behavior state machine
The EnemyState enum has exactly six states. Charging is not a state — it's the close-range run sub-mode within CHASING.
| State | Behavior |
|---|---|
| IDLE | Wander (random heading, half speed, edge-aware) or pause. Promotes to CHASING when the target enters detection_range. |
| CHASING | Navigate toward the target. Walk beyond 2× attack range, run/charge inside it. Stuck-avoidance steers perpendicular when displacement stalls. |
| ATTACKING | Stop, face target, play attack, deal damage, start cooldown. Exits to LOAFING when the animation ends. |
| LOAFING | Back off along a curving semicircle for 2.5–4s, then return to CHASING. |
| HURT | 0.3s stagger on any hit; cancels an in-progress attack; returns to CHASING. |
| DEAD | Play death animation, emit died, free after the animation. |
Attack recovery — no wedging (#477)
The ATTACKING → LOAFING transition is keyed on the attack animation ending, and the source models are inconsistent about what that animation is called (_atk, _atk1, misspelled _atckwat, suffix variants like _atk_pu / _atk_sh / _atk_a, or absent entirely — the big b_* rigs are the usual offenders). That naming variance MUST NOT be able to strand the state machine:
The attack-clip resolution order is normative (tool and runtime MUST resolve identically — see /mechanics/enemy-attacks):
- Exact clip name equal to the token.
- Any clip ending in
_<token>. - The alias table (
atk→atk1,atckwat;dmg→dam— five rigs name their damage clipdam: booma, swordman, tank, orangutan, shrimp). - Attack only — last-resort scan: any clip with an exact
atkunderscore-segment (b062_atk_pu,b052_atk_sh,m061_atk_a,b072_atk_sa_a…) qualifies, excluding clips whose final segment isst,lp, ored— those are segmented charge-up/loop/end pieces (m051_atk_sp_lp,b072_atk_gu_a_st) and playing one alone shows a partial attack. Among qualifiers the alphabetically first MUST be picked, so resolution is deterministic per rig. - Nothing resolves → the fixed fallback duration (below). A rig whose only attack clips are segmented pieces (boss_robot's
z003_atk_dl_lp) MUST take this path rather than play a bare segment.
- ATTACKING MUST terminate for every model, whatever its attack animation is named — including models with no resolvable attack animation at all, which MUST recover after a fixed fallback duration instead.
- Attack end MUST be detected against the resolved animation that was actually played (or by observing that it is no longer playing), MUST NOT rely on parsing a name suffix back out of the
animation_finishedsignal. - An enemy MUST NOT require a damage event (the HURT transition) to resume AI or animation. Being hit is a recovery path, never the only one.
- Player contact MUST NOT freeze an enemy: while the player stands against (or overlaps) an enemy of any size, its state machine and animation MUST keep advancing — attack, loaf, and chase cycles continue exactly as at range. Large and small enemies MUST behave identically under contact, differing only in authored stats.
What happens inside ATTACKING — attack selection, the windup → damaging-window timeline, and the arc hit shape — is defined in /mechanics/enemy-attacks.
Archetype: Quadruped circler (normative)
Applies to enemies with archetype: quadruped (wolf, hyena, deer, tiger rigs — Reyhound, Grimble, Tormatible, Stagg, Kapantha). Their wlk_l / wlk_r clips have the head turned to that side, so straight-ahead walking reads visibly wrong:
- While CHASING outside the charge ring, a quadruped MUST NOT walk straight toward its target. It MUST approach on an arc (circling / spiraling in), and the walk clip MUST match the geometry:
wlk_lwhen the target is on the enemy's left,wlk_rwhen on its right — the turned head faces the target. - The straight-line approach is the
sttdash-charge: inside the charge ring (attack range × charge mult) with the attack off cooldown, the enemy MAY dash straight at the target playingstt, then attack on arrival.sttis the ONLY clip that moves a quadruped straight forward. (For this familysttis not a stand/idle clip — the genericwat → sttalias never fires here since these rigs have a realwat.) - The arc side MAY change over time (re-picked on wander-like timers); a dash that loses its target (distance grows past ~1.5× the charge ring) SHOULD fall back to arcing.
Current Godot runtime diverges: it walks straight and fakes circling by alternating wlk_l/wlk_r on a timer (_walk_variant_timer) — that hack is retired when this contract is implemented (the enemy-attacks runtime PR). The #/enemy-room quadruped room implements this contract as the reference. Per-enemy quirks (Kapantha SHOULD prefer attacking from behind) and rare-as-leader pack behavior are open design — see the pack-behavior issue.
Archetype: Quad machine — hover kiter (normative)
Applies to archetype: quad_machine (quad rigs — Izhirak-S6, Azherowa-B2). These robots hover: four directional walks (wlk_f/wlk_b/wlk_l/wlk_r) are strafes relative to a body that keeps facing its target.
- While engaged, a quad machine's facing MUST stay on the target — it never turns its body to travel. Movement is strafing, and the clip MUST match the movement direction relative to facing:
wlk_fclosing,wlk_bretreating,wlk_l/wlk_rlateral. - It MUST hold a standoff band around
fsm.standoff_rangeto stay out of melee reach: target inside the band → retreat (wlk_b); target beyond it → close (wlk_f); at standoff → strafe laterally (side re-picked on a timer). It MUST NOT voluntarily close to melee range. - Attacks are ranged, fired from standoff when off cooldown and the target is inside an attack's range band:
atkis a projectile,atkblobs a grenade (area damage where it lands). Per the shared timing contract, ranged attacks release their projectile at the damaging-window open (/mechanics/enemy-attacks — attackkind). - Counterplay is intentional: the kiter does no pathfinding escape, so room geometry beats it — backing one into a corner collapses the standoff band and forces melee exposure. An implementation MUST NOT add wall-aware escape routing that defeats cornering.
Archetype: Big-rig combo (Hildegao family) — normative
Applies to archetype: bigrig_combo (gorilla rigs — Hildegao, Hildeghana, Hildegigas). Three authored attacks plus a threat display:
- Aggro threat: on noticing the target (IDLE → CHASING) the gorilla MUST play
stt— beating its chest — and hold position for the clip before pursuing. (For this familysttis the threat display, not a stand/idle clip; the melee archetypes' equivalent istht.) atk1punch combo (melee arc): heavy right-handed punch with a quick left uppercut, at melee reach.atk2running shoulder slam — the segmentedchargekind:atk2_stis the stationary windup, thenatk2_lploops while the gorilla charges forward along the facing locked at attack start, hitting on first contact (arc test each tick, one hit max — an i-frame dodge lets the slam pass by and keep going), ending after the hit or aftermax_rangeof travel with theatk2_edrecovery clip. The timeline fractions do not apply to this kind; the segments ARE the timeline.atk3belly flop — theleapkind: the gorilla jumps to the target's position (captured at the damaging-window open), traveling during the window and landing at its close for area damage (hit_reachradius); i-frames are tested at landing.- Attack trigger, generalized: CHASING → ATTACKING fires when the cooldown is ready and at least one attack's range band contains the target distance (the slam and flop fire from range). For enemies whose bands all end at melee reach this is identical to the old melee gate.
Archetype: Flyer combo (Pelcatraz family) — normative
Applies to archetype: flyer_combo (roc rigs — Pelcatraz, Pelcatobur). The rig's transitions are sparse (per kion: it feels disconnected, and it felt that way in the original game too — do not over-smooth what the source didn't have):
- Takeoff on aggro: idle is grounded; on noticing the target the roc MUST play
stt— taking flight — and rise to its hover height before pursuing. (Third meaning ofsttso far: quadrupeds dash, gorillas beat their chest, rocs take off — per-archetype clip semantics are the rule, not the exception.) - Shoulder-height hover is the design: while engaged the roc MUST stay airborne at roughly the player's shoulder height (
fsm.hover_height) — this is what makes it hard to hit with melee weapons, and an implementation MUST NOT ground it during combat. - Two locomotion clips:
fly(flaps to propel forward) while closing distance;tk(flaps to move around / turn) while hovering and orbiting near the target (orbit radius =fsm.standoff_range). - Attacks from the air:
atk1beak strike andatk3wing swipe are close melee arcs;atk2(flapping wings) is uncertain — possibly a gust attack (kion). Authored provisionally as a wider, weaker mid-range arc; an explicit open question until confirmed in-game.
Archetype: Two-attack melee + tech (Hypao family) — normative
Applies to archetype: two_attack (seal rigs — Hypao, Vespao). Standard ground locomotion; two attacks:
atk1tail swipe — close-range melee arc (wide sweep).atk2is a tech cast (barta-family ice). The attack'stechfield names the technique, and the Godot runtime MUST deliver it through the real technique system (element, freeze procs, tech visuals) — the room sim only approximates the delivery shape viakind.- Per-enemy divergence on a shared rig: Hypao casts Barta; Vespao (the rare) SHOULD cast the upgraded family tech — Gibarta authored; Rabarta is the open alternative (kion: "gibarta or rabarta"). First case of one rig with two different attack tables — the per-enemy table model exists exactly for this, and it feeds the rare-as-upgraded/leader theme (pack-behavior issue).
Archetype: Roller (Rohjade family) — normative
Applies to archetype: roller (armadillo rigs — Rohjade, Rohcrysta). The rig has no attack clip because the transform sequence IS the attack (sequence per kion's read of the clips — confirm in-game): trf1 rolls up → wat3 is the curled ball, which the engine MUST rotate while it travels (the clip has no motion of its own) → trf2 unrolls and falls over.
- Locomotion: walks (
wlk) at medium distance from the target (fsm.standoff_range), adjusting position until it has a straight path, then rolls. Idles:wat1standing,wat2lying down;sttis "become active" — held on aggro like the other display clips (fourthsttmeaning). - The roll is a
charge-kind attack with explicit segment clips (charge_segments). It MUST NOT stop on hitting the player (stop_on_hit: false) — the same animation plays whether it bowls the player over or hits a wall — and it travels past where the target stood at roll start (overshootmeters beyond the start distance, capped bymax_range), forcing an evade. - Knockdown denies the punish: a roll hit MUST knock the player down (
knockdown: true) — a hit player cannot capitalize on what follows. An i-frame dodge avoids the roll entirely. - The vulnerability window is the counterplay: while
trf2plays (the fall-over recovery), the roller MUST take increased damage (recovery_vulnerable_mult). Evade the roll → punish the sprawl. An implementation MUST NOT shorten or skip this window.
Archetype: Ape gunner (Froutang family) — normative
Applies to archetype: ape_gunner (orangutan rigs — Froutang, Frunaked). Design note (kion): this family is flagged as the most immersion-breaking content in the game — pistol-wielding monkeys — and is a candidate for replacement with EP2 enemies. The behavior is authored regardless, so the game plays correctly until that call is made.
- Aggro:
sttgoes from sitting to standing — held on aggro like the other display clips (fifthsttmeaning). Idlewatscratches its stomach. - Two pistol shots:
atckshtis the shoot attack;atcktukilooks like a different shoot attack (kion — uncertain, authored as a second projectile variant pending confirmation). - Charged punch is a clip sequence (kion):
atcksttwinds up →atckwatholds the charge →atckswgswings. Authored viawindup_clips: the windup clips play sequentially as pure telegraph (MUST NOT damage), then the swing clip carries the normal damaging window. The long telegraph is the trade for the charged damage. wtclp(raises hands above head — maybe intimidation) is unused pending confirmation — an open question; a taunt/display slot if it proves real.
Archetype: Box mimic (Bolix family) — normative
Applies to archetype: box_mimic (shrimp rigs — Bolix, Goldix). Hermit-crab behavior: it pretends to be a box and stays hidden until the player gets close, at which point it comes out and attacks. In Paru this is the "cleared the room but the gates won't unlock" moment.
- Dormant disguise: while idle the mimic MUST be stationary and disguised — holding the first frames of
stt(kion's read: the clip starts fully boxed). It MUST NOT wander and MUST NOT aggro atdetection_range— the disguise overrides normal detection entirely. - Reveal: when the target comes within
fsm.reveal_range(much smaller than detection range), the mimic playsstt— poking its head out of the box — as an aggro-display hold (sixthsttmeaning), then fights as a normal melee walker:tk1active idle,wlk1walk,atkstrike. - Reveal-cancel (kion's theory, authored provisionally): if the target backs out past ~1.5× the reveal range mid-reveal, the mimic plays
tk2— partially out, then retreats — and returns to the disguise. Whether it re-hides after a full reveal is an open question (authored: it stays active). - The sway tell (uncertain):
wlk2— the box sways around — MAY play occasionally while disguised as the subtle hint that a box is alive ("the game might give subtle hints that some boxes are actually moving"). Authored as a rare idle sway; confirm in-game. - Room-clear MUST count mimics: gates stay locked until disguised mimics are found and killed — that IS the Paru experience; the field controller's room-clear logic must include dormant mimics in the alive-enemy count.
Archetype: Hopper (Porel family) — partial
Applies to archetype: hopper (frog rigs — Porel, Pobomma, Pomarr). Authored so far (kion):
- Bubble projectile (definite): the frog shoots a bubble at the player from close-to-mid range — authored as a
projectileattack on theatkclip. - A second melee attack is an open question — kion is unsure whether they have bubble + melee or just bubbles; the rig has only one
atkclip, so a melee would share it. Not authored pending confirmation. - Hop locomotion pending:
jmp(leap) andtur(turn) clearly encode hopping movement, but its behavior isn't authored yet — the frogs use the baseline walk chase until those notes land.sttsemantics unconfirmed.
Archetype: Shooter (Korse family) — normative + leader dynamic
Applies to archetype: shooter (s050 rigs — Korse, and Akorse the leader variant). Modeled on PSO's Canadine / Canane: hovering shooters that fly with a leader. Whether the original PSZ actually implements the leader dynamic is an open question (kion) — it is specced here as the design.
- Normal mode: hovers at standoff (
fsm.standoff_range), holding position (wat) and adjusting withrun, firing theatk_shshooting attack as a projectile from its band. - Leader-loss berserk (the Canadine dynamic): when its leader (Akorse — the Canane analog) dies, a shooter SHOULD go berserk: it plays
atk_an— confused, spinning — once, then loopsatk_jilunging straight at the player and explodes on contact (self-destruct: one AoE hit, the shooter dies). The explosion triggers on contact regardless of i-frames; i-frames dodge the damage, not the blast. - The berserk kamikaze is authored as a
berserk_onlyattack (excluded from the normal attack gate); the room previews the sequence via a debug trigger since single-enemy rooms cannot host the leader relationship — pack wiring is the pack-behavior issue (#495), and Akorse-as-leader is its first concrete data point. atk_ji's remaining question: kion reads_jias the lunge (self-destruct dive);atk_anas the confusion spin;stt= "start" (semantics beyond that unconfirmed).
Poison Lily — normative (backported from the implementation)
Unlike every section above, this one was implemented first: scripts/3d/enemies/poison_lily.gd is already correct in Godot (kion), and this contract is backported from it. The lily keeps its own script (the sanctioned EnemyBase subclass exception).
- Stationary: the lily never moves; while awake it rotates to face its target. Model scale is
0.09(the raw GLB is ~11× too large — the tool renders with the samemodel_scale). - Sleep/wake cycle: SLEEPING (
waitclooping, closed) → withindetection_range(12) or on being hit → WAKING (wakeplays once) → IDLE_AWAKE (waitolooping, tracking the target). Target beyond detection range → back to SLEEPING. - Attacks (cooldown 3s), both released at the attack clip's midpoint: within melee range (3) → a bite for
attack_base; beyond it → a poison projectile (speed 5, spawned at +1m, max flight = detection × 1.5) that dealsattack_baseon contact plus a poison DoT: 3 damage per second for 5 seconds. - Clip map (custom names, no rig prefix):
waitcsleep ·wakewake ·waitoawake idle ·attack·damege(sic) damage ·die. - The #/enemy-room sim renders scale + stationary facing + both attacks; the sleep/wake cycle itself is not simulated there (it lives in the implementation and this contract).
Status effects & elements
A hit carrying an element MAY proc a status. Trigger chance is 10% + 10% × element_level. The full status table (CombatManager.STATUS_EFFECTS):
| Status | Duration | Effect |
|---|---|---|
| freeze | 2s | immobile, takes 1.5× damage, breaks on hit |
| sleep | 2s | immobile, breaks on hit |
| stun | 3s | phase 1 immobile (1s), phase 2 can move but can't attack |
| burn | 3s | 3% max-HP/sec DoT, −0.1 defense |
| poison | 3s | 5% max-HP/sec DoT |
| slow | 3s | −0.3 evasion |
| paralysis | 3s | 50% action-skip chance |
Plus the instant devil effect (dark element): no timer — it drops the target to ¼ HP immediately. The same status MUST NOT stack on itself; the model tints to the most recent status's colour while any effect is active.
The element → status routing (CombatManager.ELEMENT_STATUS, the single source of truth) — a successful proc picks uniformly among the element's statuses, so every status above MUST be reachable:
| Element | Procs |
|---|---|
| fire | burn |
| ice | freeze or slow |
| lightning | stun or paralysis |
| dark | poison or devil (instant) |
| light | sleep |
Per-model SFX
EnemyBase.ENEMY_SFX is a map keyed by model_id, each entry naming a damage / death / attack / idle sound. On _ready the enemy picks up the entry for its model; a missing key resolves to silence for that enemy.
Minimap markers
The field minimap tracks live enemies (issue #422). Each alive enemy in the currently loaded cell MUST appear as a filled orange dot at its minimap-projected position, visually distinct from the player's directional arrow. Scoping mirrors the player marker exactly: loaded cell only, no marker cap (the count is simply the alive-enemy count in the cell), and off-cell enemies MUST NOT get a marker.
| Enemy event | Minimap marker behavior (MUST) |
|---|---|
| Normal enemy present in loaded cell | a filled orange dot at its minimap-projected position |
Boss (EnemyData.is_boss) present in loaded cell | a filled orange dot ≈8× the area of a normal-enemy dot |
| Enemy moves | the marker position updates to follow it |
| Enemy dies | the marker is removed |
| Enemy not in the currently loaded cell | no marker |
The marker set MUST stay in sync with the live roster via the death signals (died / defeated) — no stale dots after a kill.
Implemented by
scripts/autoloads/enemy_registry.gd(EnemyRegistry) — loads everydata/enemies/*.tresEnemyDataat boot and serves the roster keyed byid.scripts/autoloads/enemy_spawner.gd(EnemySpawner) —generate_wave()picks enemies by area/difficulty pool and rolls per-tier (common/uncommon/rare → normal/elite) stat instances.scripts/autoloads/combat_manager.gd(CombatManager) — attack/damage resolution, the element-weakness matrix, and the status-effect table (STATUS_EFFECTS).scripts/3d/enemies/enemy_base.gd(EnemyBase) — the per-enemy node: theEnemyStateAI state machine, animation, status application, and per-model SFX.scripts/3d/field/room_minimap.gd(RoomMinimap) — the live enemy dot layer:track_enemy/ death-signal untrack, per-frameupdate_enemiesprojection, boss-sized dots.