Enemy Attacks
The key words MUST, MUST NOT, SHOULD, and MAY are used as in RFC 2119.
Enemy attacks share one data model: the per-enemy attack table in data/enemy_attacks.json, tuned in the #/enemy-room web tool (the enemy-side sibling of #/combat-room). The tool's simulation, its overlays, and the Godot runtime all read the same table — they MUST NOT use different shapes or timings. The behavior state machine (IDLE → CHASING → ATTACKING → LOAFING → HURT) stays owned by /states/enemies; this page defines what happens inside ATTACKING and how an attack is chosen.
Data model — data/enemy_attacks.json
Keyed by enemy id (the data/enemies/*.tres id). Seeded from the .tres registry by scripts/tools/gen_enemy_attacks.py (merge-preserving — re-runs keep hand-tuned rows); the stats block mirrors .tres values. Resolved (#509 Godot PR): .tres is authoritative for stats (attack_base, ranges, cooldown) and the JSON is authoritative for the attack tables + fsm params only — the runtime (EnemyAttackRegistry) reads attack defs from the JSON and stats from EnemyData, and ignores the JSON stats block (it stays purely a mirror for the web sim). No two masters.
| Field | Meaning |
|---|---|
clip | Animation-name token as the runtime resolver consumes it (atk, atk2, atk_hi…) — NOT a full clip name. Resolution = exact match, *_<token> suffix, the ANIM_ALIASES table, then (attack only) the last-resort atk-segment scan excluding st/lp/ed segment pieces — the normative order lives in /states/enemies and MUST be identical in tool and runtime. |
windup_frac / damage_end_frac | Fractions of the resolved clip's duration. Telegraph = [0, windup_frac), damaging window = [windup_frac, damage_end_frac] — windup end and damage start are definitionally the same instant. |
hit_half_angle_deg / hit_reach | The arc hit shape (below). |
min_range / max_range | Selection band on the distance to the target at the moment ATTACKING is entered. |
tech | Optional: the attack casts a named game technique (barta, gibarta…). The Godot runtime MUST deliver it through the real technique system — element, status procs (barta family freezes), tech visuals; the room sim only approximates the delivery shape via kind. First users: Hypao (barta) / Vespao (gibarta); the mother casters' tec_* clips will use it too. |
kind | Hit delivery: melee_arc (default — the arc test below), projectile (straight projectile released at window open; hits on contact, i-frames at impact), lob (grenade released at window open toward the target's position, landing for area damage; i-frames at landing), charge (segmented _st windup → _lp loop while moving forward along the locked facing, hitting on first contact, → _ed recovery; travel capped at max_range; timeline fractions do not apply — the segments are the timeline), or leap (the enemy itself travels to the target's window-open position during the window, landing at its close for area damage; i-frames at landing). All kinds resolve at most once per attack. |
windup_clips | Optional list of clip tokens played sequentially before the attack clip as pure telegraph — they MUST NOT damage. The timeline fractions apply to the attack clip itself, offset past the windup total. First user: the ape gunner's charged punch (atckstt wind-up → atckwat charged hold → atckswg swing). |
charge extras: charge_segments / overshoot / stop_on_hit | For kind: charge. charge_segments names the st/lp/ed clips explicitly when the rig doesn't use _st/_lp/_ed suffixes (the roller's trf1/wat3/trf2); the engine MUST rotate loop clips that have no motion of their own (wat3 curled ball). overshoot makes the travel target = start distance + overshoot (capped by max_range) instead of always max_range. stop_on_hit: false lets the charge roll through the target instead of ending on contact. |
berserk_only | Excluded from the normal attack gate; used only by a berserk mode (the shooter's leader-loss kamikaze — spec /states/enemies §shooter). Its hit_reach is the contact-explosion AoE radius and damage_mult the blast damage. |
knockdown / recovery_vulnerable_mult | knockdown: true — a hit MUST knock the player down (denies the follow-up punish; the runtime maps this to the player knockdown reaction). recovery_vulnerable_mult — while the attack's recovery clip plays, the enemy MUST take this damage multiplier (the roller's fall-over punish window); the room surfaces it in the log/overlay. |
weight | Relative selection weight among in-band candidates. |
damage_mult | Damage dealt = stats.attack_base × damage_mult (replaces the legacy fixed 10). |
fsm overrides | Per-enemy walk/charge multipliers, loaf durations, hurt duration, attack_fallback_duration; plus archetype params — standoff_range (kiter distance / flyer & roller orbit), hover_height (flyers), reveal_range (box mimic: the disguise-break distance that replaces detection). Defaults = today's enemy_base.gd constants; SHOULD-level tuning freedom. |
clip_notes | Optional, documentational (MAY): authored clip semantics keyed by token — what each clip on the rig is (idle vs attack-ready vs transition). Shown in the tool next to the clip picker; the runtime ignores it. First worked example: garapython. |
model_scale / idle_clip / fsm.stationary | Entry-level render scale for oversized source GLBs (poison lily: 0.09 — the runtime applies the same factor); the engaged-idle clip token for rooted enemies; and the rooted flag itself (never moves, faces the target, attacks from its bands). |
archetype | Behavior-archetype id from the table below, stamped by the seeder from model_id (classification lives in gen_enemy_attacks.py MODEL_ARCHETYPES, not in hand edits). Every enemy MUST carry a known archetype — an unclassified model fails the data test, forcing a decision when new rigs land. Selects the enemy's room at #/enemy-room/<archetype> and, later, the runtime behavior module. |
Every non-boss enemy MUST have an entry with ≥1 attack. An enemy missing from the file MUST fall back to the defaults block plus one default atk attack — it never crashes and never refuses to attack.
Attack selection
- CHASING → ATTACKING triggers when the attack cooldown is ready and at least one attack's
[min_range, max_range]band contains the target distance (ranged/charge attacks fire from range; for all-melee tables this is identical to reaching attack range). - On the transition, the candidate set is the attacks whose band contains the current target distance. The pick MUST be weighted-random among candidates.
- An empty candidate set MUST fall back to the attack whose band is nearest to the distance — a non-empty table never yields "no attack".
- Facing MUST lock at attack start (current
_start_attackbehavior, kept) — the arc does not track the target during the swing.
Attack timeline — frame-tied damage
This section AMENDS the current runtime, which deals a fixed 10 damage instantly at attack start (enemy_base.gd _start_attack) — untelegraphed and undodgeable. Per the working agreement this contract lands first; the Godot implementation follows in the next PR.
- An attack MUST NOT deal damage during the windup (
[0, windup_frac)) — that interval is the player's telegraph. - During the damaging window the arc test runs each frame; the first frame the target passes resolves the hit. One attack MUST hit a given target at most once; after resolution (hit or dodge) the attack MUST NOT re-test that target.
- After
damage_end_fracthe attack MUST NOT deal damage; the remainder of the clip is recovery. - Damage dealt =
attack_base × damage_mult.
Hit shape — the arc
A flat arc: apex at the enemy's origin, aimed along the facing locked at attack start. A target is inside iff its XZ distance ≤ hit_reach + the target's collision radius AND the XZ angle from the locked facing ≤ hit_half_angle_deg. Deliberately simpler than the player weapon cone (targeting): no apex pull-back, no vertical bound. The tool's overlay and the runtime hit test MUST be this same shape.
Dodge interaction
A target that is invincible (dodge i-frames — the first 0.2 s of a dodge, /mechanics/dodge) at its first arc-test pass is dodged: the attack consumes its one resolution on it and MUST NOT re-test that target for the rest of the window. I-frame timing beats the window by design — a well-timed dodge through the arc is safe even if the dodge ends inside the window.
Recovery & missing clips (#477 layering)
The attack-recovery MUSTs in /states/enemies apply unchanged: attack end is keyed on the resolved clip (or the fallback timer), never on a damage event. Layered on top:
- When no clip resolves for an attack's token, the timeline fractions MUST apply to
attack_fallback_duration— frame-tied damage still functions on clip-less rigs (armadillo). - A configured
cliptoken that doesn't resolve on the rig MUST degrade to that same fallback path; the tool SHOULD flag it visibly (it renders the token red with the fallback note).
Difficulty scaling — aggression & timing (#522)
Enemies scale their aggression and timing by the session difficulty (SessionManager.get_session().difficulty, one of normal / hard / super-hard). Normal is the identity baseline — the current passive feel is preserved exactly. Higher tiers widen aggro and tighten cadence/telegraph; this is deliberately the only knob difficulty turns here — HP and damage scaling stay with damage math (#157) / the attack model, not this section.
detection— multipliesdetection_range(the IDLE → CHASING aggro radius). Higher = spotted from farther.cadence— multipliesattack_cooldown. Lower = attacks more often.reaction— multiplies the windup portion of the timeline (windup_frac × duration). Lower = shorter telegraph, strikes sooner. The damage-window end is unchanged.
| Tier | detection | cadence | reaction |
|---|---|---|---|
normal | 1.0 | 1.0 | 1.0 |
hard | 1.25 | 0.80 | 0.85 |
super-hard | 1.5 | 0.65 | 0.70 |
The table is SHOULD-level starting calibration (EnemyBase.AGGRO_SCALING) — tuned by play-test, not normative to the decimal. What is normative: Normal MUST be identity, higher tiers MUST NOT reduce aggro/cadence pressure, and the scaling MUST apply to aggro/timing only (never HP/damage from this path).
Pre-strike telegraph & stance model (#491)
Resolved (playtest #491): striking straight from locomotion read as "walk up, freeze, cheap hit" — no anticipation, unreadable. So ATTACKING MUST open with a telegraph sub-phase before the damage window: the enemy holds a readable attack-ready pose, facing the target, long enough to react (run, evade, or commit). The telegraph deals no damage; it is not a 7th FSM state — it is the head of ATTACKING (the six states in /states/enemies stand).
- Stance risers (garapython/garahadan,
m_003) MUST rise viastt(wlk → wat2), holdwat2(raised attack-ready) for the telegraph beat, strike from that pose (atk1/atk2), then lower viawt2was they peel off. The authoredclip_notesare the contract. - Every other rig holds its idle (
wat) for the same beat — a best-effort readable wind-up so nothing strikes instantly. Per-archetype anticipation clips refine this later (#494). - Hold duration =
TELEGRAPH_HOLD(SHOULD-level, tuned by feel) × the difficultyreactionmultiplier — Normal is generous, higher tiers shorten it. A hit during the telegraph (HURT) cancels it, like any attack.
The deeper stance sub-state question (persistent low/raised modes with per-enemy transition tables beyond attack telegraphs) remains open under #491; this contract covers the attack-time telegraph, which is what the feel needs first.
Rig groups & behavior archetypes
PSZ enemies behave differently by type and by interaction — the single chase → attack → loaf melee loop this page's FSM parameters tune is only one archetype. The rigs themselves define the minimum behavior set (generated by scripts/tools/enemy_anim_groups.py, which parses every enemy GLB's clip names; re-run it and reconcile this table when rigs change):
- Exact sharing: models with an identical clip-name set share one rig — behavior defined for one member MUST apply to all of them, with only data deltas (stats, damage, weights) per enemy. 22 such groups exist; the non-obvious ones: booma+jigobooma (
s071), leg+lower+blacks (m061— Derreo, Zerreo, Eulid, Eulidveil), board×3, swordman×4, mother_gun+mother_tech. - Token vocabularies (clip names minus the rig prefix) collapse the 47 regular models into the archetype table below. Each row SHOULD get one worked, authored example (garapython-style
clip_notes+ attack table); the rest of the row inherits its definition. A row's behavior becomes normative when it is specced in /states/enemies — until then rows are descriptive intake targets (#492). - One room per archetype: each in-scope row has a dedicated capture/preview room at
#/enemy-room/<archetype>(index at #/enemy-room), scoped to that archetype's enemies and stating what the sim does and does not yet model for the row (per-archetype sim modules are #494).
| Archetype | Vocabulary signature | Models |
|---|---|---|
| Simple melee | atk, ded, dmg, stt, wat, wlk (+run/tht/atkb variants) | bat, circle, vulture, lizard, rabbit, lion (+recolors/rares) |
| Quadruped circler (specced — /states/enemies §quadruped) | wlk_l/wlk_r head-turned arc walks + stt straight dash-charge + stp | wolf, hyena, deer, tiger |
| Quad machine (specced — /states/enemies §quad-machine) | hover kiter: 4-dir strafes wlk_b/f/l/r, atk projectile + atkb grenade lob | quad |
| Bruiser (height swings) | atk / atk_hi / atk_mi | booma (=jigobooma) |
| Big-rig combo (specced — /states/enemies §big-rig) | atk1 punch combo, atk2_st/lp/ed running shoulder slam (charge), atk3 belly-flop leap, stt chest-beat threat | gorilla |
| Flyer combo (specced — /states/enemies §flyer) | stt takeoff on aggro, shoulder-height hover, fly approach / tk orbit, atk1 beak / atk2 gust? / atk3 wing swipe | roc |
| Two-attack melee + tech (specced — /states/enemies §two-attack) | atk1 tail swipe, atk2 barta-family tech cast (Hypao: barta · Vespao: gibarta/rabarta) | seal |
| Stance riser (#491) | wat1/wat2 + stt rise / wt2w lower | snake (authored); shinowa's wat2/backstep/transform is adjacent |
| Roller (specced — /states/enemies §roller) | rolls into a ball: trf1 roll-up → wat3 engine-rotated ball travel (through the player, knockdown) → trf2 fall-over vulnerability window | armadillo (Rohjade, Rohcrysta) |
| Ape gunner (specced — /states/enemies §ape-gunner; EP2-replacement candidate) | pistol shots atcksht + atcktuki(?), charged punch sequence atckstt → atckwat → atckswg, stt sit-to-stand aggro, wtclp intimidation(?) | orangutan (Froutang, Frunaked) |
| Box mimic (specced — /states/enemies §box-mimic) | dormant disguise (holds stt first frames; wlk2 sway tell?), reveal_range pop-out, tk2 peek-retreat cancel(?), then tk1/wlk1/atk melee | shrimp (Bolix, Goldix) |
| Hopper (partial — /states/enemies §hopper) | bubble projectile from close-to-mid range (definite); melee second attack an open question; jmp/tur hop locomotion pending | frog (Porel, Pobomma, Pomarr) |
| Rappy | the rare bonus enemies — not actually tricksters (kion), just rare; dual ded1/ded2 + stt1/stt2 suggest the classic fall-down-then-flee | rappy (Rappy, Ar Rappy, Rab Rappy) |
| Shooter (specced — /states/enemies §shooter; PSO Canadine/Canane) | standoff hover + atk_sh projectile; leader-loss berserk: atk_an confusion spin → atk_ji kamikaze loop → contact explosion (Akorse is the leader — #495) | shooter (Korse, Akorse) |
| Missile tank | fires missiles at the player: atk_bz / atk_mi / atk_sh, segmented run_st/lp/ed | tank (Phobos, Phobos Dyna) |
| Boarder | robots riding a surfboard — highly mobile, hard to track down: atk_sh, atk_sp_st/lp/ed, run_st/lp/ed | board (Finjer R/B/G) |
| Shade | the Dark Shrine enemies — look like the heartless from Kingdom Hearts (kion); grouped as one category pending behavior notes | leg/lower + swordman (Derreo, Zerreo, Eulid, Eulidveil, Arkzein[s], Zaphobos[es]) |
| Mother caster | segmented atk_gu_*, tec_* casts, warp_st/ed teleport | mother ×4 |
| Uniques | mole (grd01–03 digs), shinowa; poison_lily backported from its implementation (/states/enemies §poison-lily: rooted, sleep/wake, bite-or-poison-spit at the clip midpoint, scale 0.09) | — |
EnemyData.behavior already enumerates MELEE / RANGED / CHARGER / SWARM / TANK / BOSS but is largely unused by the runtime; as archetype rows get specced, that enum (or a successor keyed to this table) becomes the runtime dispatch. Bosses are explicitly out of scope for #/enemy-room: their five vocabularies are each unique and bound to their specific arena (movement anchors, phase geometry). That dedicated tool now exists at #/boss-room (#493): one room per boss, loading the boss rig inside its own arena stage (mapping in data/boss_arenas.json, seeded from the quests' boss segments), with a walkable player capsule, clip playback, and first-class st/lp/ed segment-chain playback. It is the observation instrument — draft boss phase/attack tables now live in boss_arenas.json (schema_version: 2) under the contract at /states/bosses, which reuses this page's attack timeline model (chains generalize windup_clips; unset fields take this page's defaults) and extends the kind set with boss-only kinds. boss_robot's full 26-clip kit is imported (patched apicula — the rig carries one extra unanimated root object apicula's exact-count match rejected; see /states/bosses §chaos-mobius).
Tooling & data flow
- #/enemy-room runs the full FSM (a pure TS port,
web/src/enemy-room/fsm.ts— the normative reference forselectAttackandarcHitTest) against a WASD player dummy with dodge i-frames, drawing detection/attack/charge rings and the arc with telegraph → hot window tints. Its clip dropdown lists the rig's actual clips — clip availability is never assumed. - Export replaces
data/enemy_attacks.jsonwholesale;scripts/tools/gen_enemy_attacks.pyreseeds new enemies without clobbering tuning. The committed file is validated byweb/src/__tests__/enemy-attacks-data.test.ts. - The sandbox is a flat arena: nav-mesh, floor rays, stuck detection, and status effects are out of contract here (owned by the runtime + /states/enemies).
Implemented by (next PR)
scripts/3d/enemies/enemy_base.gd—_start_attack/_process_attackinggain the selection, timeline, and arc test; a loader fordata/enemy_attacks.json.- Two-layer tests per the working agreement: seeded
test_runnerunits + an autopilot probe (thePSZ_AUTOPILOT_ENEMY_FREEZEprobe pattern extends naturally).