Skip to content

Articles

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 start

A 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.1

Release-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.IDLE

The 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_SLIDE state before wall detection is reliable. is_on_wall() plus a clamped fall speed gets 90 % of the feel with one condition.
  • A DASH state 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 ATTACK state for a heavy attack. Add a timing table to the existing one instead; two attack states duplicate every cancel rule.

Common mistakes

  • Jump in _input instead 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 after move_and_slide() in the physics step.
  • Resetting velocity.y on 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_entered only. An attack that starts while two bodies already overlap never fires that signal; toggle monitoring and read overlaps instead.