Godot 2D platformer: state machine, attacks, and enemies
A 2D platformer in Godot 4 built as one explicit state machine: idle, run, jump, fall and attack, coyote time, hitboxes that only fire on active frames, and enemies that react.
The 2D platformer quick startA platformer feels wrong before it looks wrong. Double jumps that only sometimes trigger, an attack that hits during recovery, a character that sticks to a wall for one frame — those are not animation problems. They are one problem: the character has no single place that says what it is currently doing.
This is the shape that fixes it: one CharacterBody2D, one enum, one _physics_process that
dispatches. Every mechanic below is a state, not a flag.
1. One enum, one dispatcher
extends CharacterBody2D
enum State { IDLE, RUN, JUMP, FALL, ATTACK, HURT }
const SPEED := 130.0
const ACCEL := 1400.0
const JUMP_VELOCITY := -360.0
var state: State = State.IDLE
var facing: int = 1
func _physics_process(delta: float) -> void:
# Gravity applies in every state except the ones that deliberately suspend it.
if state != State.ATTACK:
velocity.y += ProjectSettings.get_setting("physics/2d/default_gravity") * delta
match state:
State.IDLE, State.RUN: _state_grounded(delta)
State.JUMP, State.FALL: _state_air(delta)
State.ATTACK: _state_attack(delta)
State.HURT: _state_hurt(delta)
move_and_slide()
_update_state()move_and_slide() appears once. When it appears twice, one call runs before the state changed
and the frame uses last frame's velocity — the classic "jump feels delayed sometimes".
2. Ground, air, and the two timers that make jumps feel right
Two small timers remove almost all "unfair" jump deaths:
- Coyote time — jump still works for ~0.1 s after walking off a ledge.
- Jump buffer — a jump pressed ~0.1 s before landing fires on landing.
var coyote := 0.0
var buffer := 0.0
func _state_grounded(delta: float) -> void:
coyote = 0.1
buffer = maxf(buffer - delta, 0.0)
var input_x := Input.get_axis("move_left", "move_right")
velocity.x = move_toward(velocity.x, input_x * SPEED, ACCEL * delta)
if buffer > 0.0:
_jump()
func _state_air(delta: float) -> void:
coyote = maxf(coyote - delta, 0.0)
var input_x := Input.get_axis("move_left", "move_right")
velocity.x = move_toward(velocity.x, input_x * SPEED, ACCEL * delta)
if is_on_wall() and velocity.y > 0.0:
velocity.y = minf(velocity.y, 60.0) # wall slide, slow but not stuck
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("jump"):
buffer = 0.1Release-to-shorten (if event.is_action_released("jump") and velocity.y < 0.0: velocity.y *= 0.5)
belongs in the same handler. It is the difference between a jump that feels like a platformer
and one that feels like a trampoline.
3. Attack: startup, active, recovery
An attack is not a boolean. It is three windows, and only one of them can hit.
const ATTACK_STARTUP := 0.08
const ATTACK_ACTIVE := 0.10
const ATTACK_RECOVERY := 0.18
var attack_t := 0.0
func _state_attack(delta: float) -> void:
velocity.x = move_toward(velocity.x, 0.0, ACCEL * 2.0 * delta)
attack_t += delta
$Hitbox.monitoring = attack_t >= ATTACK_STARTUP and attack_t <= ATTACK_STARTUP + ATTACK_ACTIVE
if attack_t >= ATTACK_STARTUP + ATTACK_ACTIVE + ATTACK_RECOVERY:
attack_t = 0.0
$Hitbox.monitoring = false
state = State.IDLEThe Hitbox is an Area2D on the body, positioned forward with facing, and its
monitoring is false in every other state. That single line removes double hits, hits during
recovery and hits while stunned — three bugs that otherwise need three fixes.
4. Hurt, invulnerability, and knockback
Damage is a state with a timer, not a health -= 1 that also flips a sprite.
@export var max_health: int = 3
var health: int
var invuln := 0.0
func take_hit(from: Vector2, damage: int = 1) -> void:
if invuln > 0.0 or state == State.HURT:
return
health = maxi(health - damage, 0)
invuln = 0.6
state = State.HURT
attack_t = 0.0
velocity = Vector2(signf(global_position.x - from.x) * 180.0, -220.0)The early return is the whole invulnerability system. Without it, two enemy hitboxes that overlap on the same frame deal two damage and the player dies to one touch.
5. Enemies are the same machine, smaller
Enemies that share the pattern cost nothing to add and behave predictably:
| State | Behaviour |
|---|---|
| PATROL | walk until a RayCast2D forward hits a wall or the floor ray finds a gap, then flip |
| CHASE | if the player is inside an Area2D and on roughly the same y, walk toward them |
| ATTACK | the same three-window attack as the player, with its own hitbox |
| HURT | knockback and a short stun; reuse take_hit with different numbers |
Give every hittable node the group hittable and let the hitbox call
get_overlapping_areas() — do not hard-code the player's node path in the enemy script. That
path breaks the first time the player scene is renamed, and the symptom is an enemy that walks
into you and does nothing.
6. States you should not add
- A
WALL_SLIDEstate before wall detection is reliable.is_on_wall()plus a clamped fall speed gets 90 % of the feel with one condition. - A
DASHstate to fix slow movement. If the run speed feels slow, raise the speed; a dash hides the problem and adds a cooldown, i-frames and a cancel window to test. - A second
ATTACKstate for a heavy attack. Add a timing table to the existing one instead; two attack states duplicate every cancel rule.
Common mistakes
- Jump in
_inputinstead of buffering it. Input can arrive between physics frames; the jump is then swallowed or doubled. is_on_floor()in_process. It is only valid aftermove_and_slide()in the physics step.- Resetting
velocity.yon state change. It kills upward momentum and makes the jump arc a set of steps. - Hitbox parented to the sprite. The sprite flips; the hitbox flips with it and hits behind you.
- Damage applied in
_on_body_enteredonly. An attack that starts while two bodies already overlap never fires that signal; togglemonitoringand read overlaps instead.