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); Mother casts currently use provisional neutral delivery until their named techniques are verified. |
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), lunge (one clip: locked-facing collision-limited travel and swept contact during its damage window, stationary preparation/recovery; max_range caps travel and hit_reach is the contact radius), 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. |
recovery_clip | Optional harmless end clip after the main attack. The enemy MUST remain stationary and cannot select another attack until this clip finishes, then enters loafing. Hurt or death cancels the sequence. It does not extend the main clip's damaging window. |
projectile_visual / projectile_color | Optional Godot presentation: sonic ring or default orb, with RGB hex color. These MUST NOT change the contact radius or imply an elemental status. The web room uses its generic projectile marker. |
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 | Optional spawn_clip names an entrance played once at room reveal (Booma: stt), separate from attack windup. 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.
Straight projectiles MUST sweep their planar contact radius across the full physics step, clamped to their remaining range. First contact consumes the shot exactly once. A contact during dodge i-frames MUST suppress both direct damage and on-hit effects (including poison); dodging MUST NOT merely defer damage into a later status tick. This swept test supersedes endpoint-only checks in both the runtime and web simulator.
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 |
|---|---|---|
| Lunging melee | atk close claws; atkb traveling spin | lion, lion_rare |
| Simple melee | atk, ded, dmg, stt, wat, wlk (+run/tht/atkb variants) | bat, circle, vulture, lizard, rabbit (+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 (prepared dash) | atk prepare → run dash → atk_mi recovery | 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).