Player State Machine
The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL below are used as in RFC 2119.
The player avatar is a CharacterBody3D driven by a single explicit state machine. Exactly one PlayerState is active at a time, held in current_state. Every frame, _physics_process dispatches on that state to a per-state handler, then runs move_and_slide(). State changes go through one chokepoint, transition_to(new_state), which emits the state_changed signal and plays the entry animation. This page is the behavior reference the planned player.gd split (see Implemented by) MUST preserve.
States
IDLE
The resting state. On entry, transition_to MUST zero horizontal velocity (velocity.x and velocity.z) so carry-over from a dodge or attack does not slide the avatar into the wait pose; the Y component is preserved so gravity/falling still reads correctly. The avatar plays the looping <prefix>_wait animation. IDLE is the universal fall-back: nearly every other state returns here when its animation finishes or its activity ends.
WALKING / RUNNING
The two active locomotion states, distinguished by speed and animation. Movement input from IDLE enters WALKING (with the walk-to-run timer reset). Holding movement in WALKING for WALK_TO_RUN_DELAY (1.2 s) promotes to RUNNING. Both share _handle_movement; releasing all movement input returns to IDLE.
Locomotion is exactly IDLE / WALKING / RUNNING. (PlayerState.SPRINTING was an unreachable dead state — no code path ever transitioned into it — and was removed in #273; test_player_states pins the enum so it cannot quietly return.)
ATTACKING
Active during a melee swing, projectile shot, or technique cast. Entering ATTACKING from a non-attacking state swaps the weapon hold orientation to attack (_set_weapon_hold("attack")); leaving ATTACKING for any other state swaps it back to idle. Horizontal velocity is held at zero for the whole state (_handle_attack_state). The avatar leaves ATTACKING when the attack/technique animation finishes, when the combo window is missed, or when the final combo step completes — all routing to IDLE. See Combat Combos and Techniques.
DODGING
A forward roll in the avatar's facing direction at the moment the dodge started. The avatar stays in DODGING for the full clip; the move phase applies velocity, the recovery phase zeroes it. DODGING MUST end in IDLE — either when the dodge animation finishes (_on_animation_finished) or, as a backstop, when DODGE_DURATION (0.8 s) elapses in _handle_dodge. See Dodge.
DAMAGED
An animation-driven hit reaction (light/medium hit, or the wake-up after a knockdown). No physics movement occurs — _handle_damaged holds horizontal velocity at zero. When the hit-reaction animation finishes, the avatar MUST return to IDLE.
DOWN
The knockdown state, entered on a heavy hit (>20 damage) or a lethal hit. Like DAMAGED it is animation-driven with no physics movement (it shares _handle_damaged). When the knockdown animation finishes, behavior MUST branch on HP: if HP ≤ 0 the avatar stays DOWN playing the lying-down loop (<prefix>_dam_d_lp); otherwise it plays the wake-up clip and transitions DOWN → DAMAGED, which then falls through to IDLE when the wake-up finishes.
CUTSCENE
A scripted lockout. Gameplay input is gated off at the top of _unhandled_input (it returns early when current_state == CUTSCENE), and _physics_process zeroes horizontal velocity. The avatar plays the looping wait pose. Entry and exit are driven externally (by scripted sequences), not by the player controller itself.
STUNNED
Reserved. Declared in the PlayerState enum but currently has no transitions into it, no handler in _physics_process, and no entry case in transition_to. It is kept as a reserved, not-yet-implemented state for a future stun/status effect — unlike SPRINTING, it stays in the enum through the refactor.
Action commitment (#377)
PSZ has no mid-animation interrupting actions — a started action commits. Dodge is free (no stamina), so its cost is animation commitment plus a short i-frame window: a poorly-timed roll is a punish. Deliberately "clunky like Castlevania — think before you act"; PSO2-style animation canceling MUST NOT be added. The authoritative table:
| Action | Player-cancelable by | Interrupted by | Notes |
|---|---|---|---|
| Attack (N/H/S swing) | nothing — must fully execute | damage | the swing finishes before any dodge/next-action input registers |
| Dodge / roll | nothing (NOT by attack) | damage outside the i-frame window | i-frames ≈ 11 frames ≈ 0.2 s from roll start (mid-weight-roll feel) |
| Hold-to-charge (tech) | dodge, N/H/S attack, Start Menu open, Quick Menu open | damage | the five drop cases — #352, #377 |
- A player-initiated dodge input while ATTACKING MUST NOT leave ATTACKING —
_start_dodgeis a no-op during ATTACKING (and during DODGING; a roll cannot restart itself). - A player-initiated attack input while DODGING MUST NOT leave DODGING —
_start_attack/_start_strong_attackare no-ops during DODGING (technique casts were already gated). - Damage-initiated transitions (
take_damage→ DAMAGED/DOWN) remain allowed out of both states; they are interrupts, not cancels. - Combo chaining (ATTACKING → ATTACKING inside the combo window) is not a cancel — the previous swing's animation has already opened the window; chaining stays legal.
Attack hitbox lifetime (#428)
Model update (spec /mechanics/targeting): melee hits now resolve once, at the swing's damaging frame, directly through the weapon's hit cone — the always-on Area3D box is retired for melee. The lifetime invariant below is kept verbatim as defense-in-depth (the node still exists and every ATTACKING exit still deactivates it), and a damage interrupt before the damaging frame means the swing lands nothing.
The attack hitbox's active window MUST be strictly bounded by the ATTACKING state — it MUST NOT outlive ATTACKING by any exit path. On every transition out of ATTACKING (normal swing end, damage interrupt, or any future exit), the hitbox MUST be deactivated in the same frame as the state change: monitoring = false and _hit_targets cleared, so max_targets / hits_per_target accounting starts clean on the next swing (no "stored" hits). The hitbox MUST NOT register hits while the player is in DODGING / IDLE / any locomotion state. Enforced centrally in transition_to on the ATTACKING-exit edge (defense in depth — it holds even if a new interrupt path is added later), in addition to the per-path deactivations at swing end.
Invariants
- Exactly one state is active at all times; all changes go through
transition_to. - Entering IDLE MUST zero horizontal velocity (preserving Y).
- Entering ATTACKING from a non-attacking state MUST set the weapon to its attack hold; leaving ATTACKING MUST restore the idle hold.
- A technique that is mid-charge MUST be cancelled by exactly the five drop cases (see Action commitment): DAMAGED/DOWN/DODGING transitions (
transition_toclears the slot, ends the charge visual, and emitstech_charge_releasedbefore applying the new state — #273), a mid-charge N/H/S attack or different-tech press (#352), the Start Menu opening, and the Quick Menu opening (#377 — both menus'openedsignals connect to_drop_charge). - Player-initiated transitions between ATTACKING and DODGING (either direction) MUST NOT occur — see Action commitment.
- Leaving ATTACKING by any path MUST deactivate the attack hitbox in the same frame — see Attack hitbox lifetime.
- ATTACKING, DAMAGED, DOWN, and CUTSCENE all hold horizontal velocity at zero in their handlers.
Movement
Handled by _handle_movement for the IDLE/WALKING/RUNNING states. Input is read from the four directional actions and normalized, then transformed by the active camera's basis so "forward" is always into the screen (camera-relative movement); with no camera it falls back to world-space input. The avatar rotates toward the input direction at ROTATE_SPEED, and speed is scaled down toward TURN_SPEED_FLOOR (0.3) when the facing error is large, so a sharp turn tightens instead of skating forward.
Speeds by state: WALKING uses WALK_SPEED (2.5), RUNNING uses MOVE_SPEED (6.0).
Before committing horizontal velocity, _can_move_to raycasts three downward probes (center plus left/right offsets) ahead of the avatar and requires at least 2 of 3 to find floor (_has_floor_at); this prevents walking off ledges while tolerating hairline gaps in the floor mesh. If the full direction is blocked, movement is retried on each axis separately so the avatar slides along the obstruction rather than stopping dead. After move_and_slide(), _apply_step_up probes ahead at several distances and, if the avatar is on a wall with walkable floor just above its lip (≤ STEP_UP_MAX_HEIGHT, 0.5 m), snaps it up onto the step — CharacterBody3D does not auto-climb steps. Gravity (GRAVITY = 20) is applied while not on floor, and falling below FALL_RESPAWN_Y respawns the avatar at its spawn position. Footstep SFX fire on an interval that tightens from walk to run.
Dodge
_start_dodge caches the current facing as dodge_direction, resets the dodge timer, and looks up the active weapon's <prefix>_esc_f clip length so the move phase scales to that weapon's roll (falling back to DODGE_DURATION if the clip is missing). The move phase lasts DODGE_MOVE_FRACTION (0.7) of the clip; during it, _handle_dodge drives velocity at DODGE_SPEED (7.0) in the cached direction, but only if _can_move_to still finds floor — so the avatar will not roll off a ledge. The remaining recovery fraction holds velocity at zero while the crouch-and-stand animation plays out. Dodge is bound to a dedicated button (L1) and is gated so it cannot start while DAMAGED or DOWN. The baked root-translation track of _esc_f clips is stripped at load so gameplay velocity is the sole source of horizontal motion.
The dodge MUST grant invincibility frames for the first DODGE_IFRAME_DURATION (0.2 s, ≈ 11 frames) of the roll — a hit landing in that window does no damage and does not interrupt the roll. After the window, the rest of the roll (late move phase and the full recovery) is vulnerable, and a hit interrupts it (DODGING → DAMAGED/DOWN). History: #273 originally granted i-frames for the whole move phase (dodge_timer < dodge_move_end, ≈ 0.47 s for saber); #377 tightened the window to the fixed 0.2 s per the authoritative commitment table (see the Dodge mechanic page).
Attacks & combos
Combos are tracked by combo_state (0 = not attacking, 1..N = combo step) with a single-slot next-step queue (_queued_combo). _start_attack begins a fresh combo (step 1, ATTACKING) or, if already ATTACKING, routes the press through the two-tier timing (#461): a press at or after the just_start chain-accept boundary queues the next step, which fires when the current swing completes — never mid-swing (see Action commitment). A press before the boundary is a miss-early fumble that locks out chaining for the rest of the swing, so the swing ends un-queued and the combo breaks to IDLE. There is no just-attack window and no timing-based damage bonus — crit and damage come from stats and equipment (#461). _start_strong_attack mirrors this but flags the queued step special, so it carries the weapon's element and runs a brief charged wind-up (SPECIAL_ATTACK_DELAY) before the strike. The full normative contract, the accept boundary, and the data source live in Combat Combos. Each step resolves its hits once, at the damaging frame (_execute_attack_hit): melee hits the nearest enemies in the weapon's hit cone; ranged weapon types fire a Projectile via _fire_projectile. _update_combat_targets maintains target reticles from the same hit cone (extended to cover equipped techniques) — see Targeting & Hit Detection.
Techniques
Palette slots 1–3 map to action ids via ActionPalette. On press, if the slot's action is a technique (TechniqueManager.TECHNIQUES has the id) _on_palette_pressed begins a charge — recording the slot/id and resetting the charge timer; on release _on_palette_released casts. The per-frame charge timer in _physics_process marks the cast "ready" once it passes TECH_CHARGE_THRESHOLD (0.6 s), emitting tech_charge_ready and starting the charge visual; a release after that casts the charged variant (TechniqueManager.get_charged_technique), otherwise the base technique. Non-technique palette actions (attack, strong_attack, dodge, consumables, telepipe) dispatch immediately via _execute_palette_action. _cast_technique checks the learned level and PP cost, deducts PP, enters ATTACKING with the cast animation (no combo), and calls _spawn_technique_effect, which routes to per-spell spawners (_spawn_foie, _spawn_gifoie, _spawn_rafoie, _spawn_barta, _spawn_gibarta, _spawn_rabarta, _spawn_zonde, _spawn_gizonde, _spawn_razonde); unimplemented ids fall back to a basic projectile. See Techniques.
Action palette pages (#447)
The palette holds two pages of three slots (ActionPalette.pages); the front page (index 0) is the default. The back page (index 1) is hold-to-activate (momentary), not a latched toggle: while palette_swap (R1/LB) is held, the field palette MUST display the back page; on release it MUST return to the front page. It MUST NOT latch. The transitions MUST be edge-driven — exactly one page switch on press and one on release, with no per-frame re-trigger and no buffered toggle surviving the release (a repeat press without a release MUST NOT advance again). The release leg MUST restore the front page even when a menu, cutscene, or modal opens mid-hold — otherwise the back page would latch until the next press. Implemented: ActionPalette.show_back()/show_front() (idempotent set_page) driven from the press/release edges in player.gd _unhandled_input; the release runs before the cutscene/gameplay-blocked gates. This contract covers the field gameplay palette only — the discrete palette_swap page navigation inside menus (storage, start-menu palette editor) is a separate mode and unaffected.
Taking damage
take_damage(damage) is the public entry point for the avatar receiving a hit. It subtracts from HP, plays a hit SFX (hard vs light), and zeroes velocity (there is no physics knockback — hit reactions are animation-driven). It then branches on severity: lethal (HP ≤ 0) and heavy (>20) play the knockdown clip and enter DOWN; medium (>10) and light (≤10) play a stagger clip and enter DAMAGED. A hit while already DOWN with HP ≤ 0 (already dead) is ignored. Because the transition runs through transition_to, a mid-charge technique is cancelled on the way into DAMAGED/DOWN (see Invariants).
Implemented by
scripts/3d/player/player.gd(Player) — the entire state machine today: thePlayerStateenum,transition_to, the_physics_processdispatch,_handle_movement/_handle_dodge/_handle_attack_state/_handle_damaged,_start_attack/_start_strong_attack/_start_dodge,_cast_techniqueand the per-spell spawners,take_damage, and_on_animation_finished.
This 2,594-line script is slated to split into focused units; this page is the behavior reference that split MUST preserve:
- PlayerRig — model/weapon/mag loading and attachment, animation library setup, and the play-animation helpers (
_load_character_model,_setup_weapon,_setup_mag,_load_weapon_animations,play_animation). - TechniqueCaster — the charge lifecycle and spell casting (
_on_palette_pressed/_on_palette_released,_cast_technique,_spawn_technique_effectand the spawners). - PlayerCombat — attacks, hit resolution, and combo tracking (
_start_attack/_start_strong_attack,_handle_attack_state,_execute_attack_hit/_fire_projectile,_update_combat_targets).
The PlayerState enum, transition_to, and the _physics_process dispatch are the seam the split crosses; the contract above (one active state, the velocity/weapon-hold invariants, the charge-cancel rule, and the animation-finish routing) MUST hold whichever unit ends up owning each piece.
Planned changes (refactor punch-list)
Spec-vs-code gaps to resolve as part of the player.gd split — each is documented above as intended behavior with a "current gap"/"planned" note:
- Remove SPRINTING — done (#273 inc 1): dropped from the enum, locomotion, footsteps, and animation handling.
- Add dodge i-frames — done (#273 inc 2): move phase grants invincibility, recovery stays vulnerable. Superseded (#377): the window is now the fixed 0.2 s
DODGE_IFRAME_DURATION, not the whole move phase. - Cancel charge on DODGING — done (#273 inc 3): transition_to releases the charge on DODGING too.
- Action commitment — done (#377): attacks and dodges cannot be player-canceled; only damage interrupts. Charge drops on Quick Menu open (the fifth drop case).
- Attack hitbox lifetime — done (#428): every exit from ATTACKING deactivates the hitbox in
transition_to. - Keep STUNNED reserved — leave in the enum as a not-yet-implemented status state.