All Scrolls

How to Make a Souls-Like Game in Godot 4

Coding Quests/February 19, 2026/Tutorials

How to Make a Souls-Like Game in Godot 4

Souls-like games are one of the most ambitious genres for indie developers. Precise combat, stamina management, dodge rolls with i-frames, hit stagger, multi-hit combos, and punishing-but-fair enemy AI - the systems interlock tightly, and getting one wrong throws off the whole feel.

The good news: Godot 4 is more than capable of handling this. The engine's node system, state machines, and physics are a natural fit for the layered architecture a souls-like demands. Here's how to approach it.

The Architecture: Systems, Not Spaghetti

The biggest trap with souls-like games is trying to build everything in one script. You end up with a 2,000-line Player.gd that handles movement, combat, stamina, dodge, damage, death, and UI updates all in _physics_process. It works until you try to change anything.

The right approach is systems that communicate through signals:

CharacterBody3D (Player)
├── StateMachine
│   ├── IdleState
│   ├── WalkState
│   ├── RunState
│   ├── JumpState
│   ├── AttackState (handles combo chain)
│   ├── DodgeState
│   ├── HitSmallState
│   ├── HitBigState
│   └── DeathState
├── InputController
├── CameraController
├── AnimationTree
├── HurtboxComponent
├── HitboxComponent
└── StatsComponent (health, stamina)

Each system is independent. The StateMachine manages which behavior is active. The InputController buffers inputs so actions feel responsive. The StatsComponent tracks health and stamina. They talk to each other through signals and shared references - never by reaching deep into another system's internals.

The State Machine Is Non-Negotiable

If there's one pattern you need for a souls-like, it's a state machine. Every action the player takes - idle, walk, run, attack, dodge, stagger, die - is a state with its own entry logic, frame-by-frame behavior, and exit conditions.

GDScript
# state.gd
class_name State
extends Node
var state_machine: StateMachine
func enter() -> void:
pass
func exit() -> void:
pass
func physics_update(delta: float) -> void:
pass
GDScript
# state_machine.gd
class_name StateMachine
extends Node
@export var initial_state: State
var current_state: State
func _ready() -> void:
for child in get_children():
if child is State:
child.state_machine = self
if initial_state:
current_state = initial_state
current_state.enter()
func _physics_process(delta: float) -> void:
if current_state:
current_state.physics_update(delta)
func transition_to(new_state: State) -> void:
if current_state == new_state:
return
current_state.exit()
current_state = new_state
current_state.enter()

This is the backbone. Every feature you add - combos, dodge rolling, hit reactions - is a new state node with clean, isolated logic.

Input Buffering: Why Your Combat Feels Sluggish

The difference between responsive combat and sluggish combat often comes down to input buffering. In a souls-like, the player presses attack during a roll animation and expects the attack to fire when the roll ends. Without buffering, that input is lost.

GDScript
# input_controller.gd
class_name InputController
extends Node
var buffered_action: StringName = &""
var buffer_timer: float = 0.0
const BUFFER_WINDOW: float = 0.2 # 200ms buffer
func _process(delta: float) -> void:
if buffer_timer > 0.0:
buffer_timer -= delta
if buffer_timer <= 0.0:
buffered_action = &""
func buffer_action(action: StringName) -> void:
buffered_action = action
buffer_timer = BUFFER_WINDOW
func consume_buffer() -> StringName:
var action := buffered_action
buffered_action = &""
buffer_timer = 0.0
return action
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("attack"):
buffer_action(&"attack")
elif event.is_action_pressed("dodge"):
buffer_action(&"dodge")

States check the buffer when they're ready to transition. The attack state checks for a buffered attack to chain combos. The dodge state checks for a buffered attack to queue the next action after the roll finishes.

This one system transforms combat from "why didn't my button press register" to "this feels tight."

Combo System: Chaining Attacks

A 3-hit combo in a souls-like works like this: each attack is a phase of the attack state. The state plays an animation, opens a window for the next input, and either chains to the next hit or returns to idle.

GDScript
# attack_state.gd
extends State
var combo_step: int = 0
var can_chain: bool = false
var animations: Array[StringName] = [&"attack_1", &"attack_2", &"attack_3"]
@onready var player: CharacterBody3D = owner
@onready var anim_tree: AnimationTree = owner.get_node("AnimationTree")
@onready var input: InputController = owner.get_node("InputController")
func enter() -> void:
combo_step = 0
can_chain = false
_play_attack(combo_step)
func physics_update(_delta: float) -> void:
# Check for combo chain during the window
if can_chain:
var buffered := input.consume_buffer()
if buffered == &"attack" and combo_step < animations.size() - 1:
combo_step += 1
can_chain = false
_play_attack(combo_step)
return
func _play_attack(step: int) -> void:
anim_tree.set("parameters/attack/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
# Animation events trigger open_chain_window() and attack_finished()
# Called by animation event
func open_chain_window() -> void:
can_chain = true
# Called by animation event at the end of the attack
func attack_finished() -> void:
if not can_chain or combo_step >= animations.size() - 1:
state_machine.transition_to($"../IdleState")

The key insight: animation events drive the combo timing, not code timers. You place open_chain_window() and attack_finished() markers in your animation timeline. This means adjusting combo feel is an animation task, not a code task - which is exactly where it belongs.

Dodge Rolling With i-Frames

The dodge roll is what makes souls-like combat tactical. The player commits to a roll animation, gains a brief window of invincibility (i-frames), and exits with recovery frames.

GDScript
# dodge_state.gd
extends State
@export var dodge_speed: float = 8.0
@export var stamina_cost: float = 25.0
var dodge_direction: Vector3
var is_invincible: bool = false
@onready var player: CharacterBody3D = owner
@onready var hurtbox: Area3D = owner.get_node("HurtboxComponent")
func enter() -> void:
# Lock dodge direction at the moment of input
var input_dir := Input.get_vector("left", "right", "forward", "back")
if input_dir.length() > 0.1:
dodge_direction = Vector3(input_dir.x, 0, input_dir.y).normalized()
else:
dodge_direction = -player.global_transform.basis.z # Dodge forward
player.stats.use_stamina(stamina_cost)
is_invincible = false
# Play dodge animation - animation events handle i-frame timing
# Called by animation event
func enable_iframes() -> void:
is_invincible = true
hurtbox.set_deferred("monitoring", false)
# Called by animation event
func disable_iframes() -> void:
is_invincible = false
hurtbox.set_deferred("monitoring", true)
func physics_update(delta: float) -> void:
player.velocity = dodge_direction * dodge_speed
player.move_and_slide()
# Called by animation event
func dodge_finished() -> void:
state_machine.transition_to($"../IdleState")

Again, animation events control the timing. The i-frame window is defined by where you place enable_iframes() and disable_iframes() in the dodge animation. Tweaking the feel is drag-and-drop in the animation editor.

Hit Detection: Hitboxes and Hurtboxes

Souls-like combat uses a hitbox/hurtbox pattern:

  • Hitbox - An Area3D attached to the weapon. Enabled only during attack animations.
  • Hurtbox - An Area3D on the character's body. Always active (except during i-frames).
GDScript
# hitbox_component.gd
extends Area3D
@export var damage: int = 20
func _ready() -> void:
monitoring = false # Disabled by default
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D) -> void:
if body.has_method("take_damage"):
body.take_damage(damage)

The attack animation enables the hitbox for a few frames during the swing, then disables it. This creates precise, predictable hit windows that feel fair - the defining characteristic of souls-like combat.

Stamina: The Decision Currency

Stamina makes every action a choice. Attack, dodge, and sprint all cost stamina. Run out and you're vulnerable. This creates the risk/reward tension souls-likes are known for.

GDScript
# stats_component.gd
extends Node
signal health_changed(current: int, maximum: int)
signal stamina_changed(current: float, maximum: float)
signal died
@export var max_health: int = 100
@export var max_stamina: float = 100.0
@export var stamina_regen_rate: float = 30.0
@export var stamina_regen_delay: float = 1.0
var health: int
var stamina: float
var regen_timer: float = 0.0
func _ready() -> void:
health = max_health
stamina = max_stamina
func _process(delta: float) -> void:
# Regenerate stamina after a delay
if regen_timer > 0.0:
regen_timer -= delta
elif stamina < max_stamina:
stamina = minf(stamina + stamina_regen_rate * delta, max_stamina)
stamina_changed.emit(stamina, max_stamina)
func use_stamina(amount: float) -> bool:
if stamina < amount:
return false
stamina -= amount
regen_timer = stamina_regen_delay
stamina_changed.emit(stamina, max_stamina)
return true
func take_damage(amount: int) -> void:
health = maxi(health - amount, 0)
health_changed.emit(health, max_health)
if health <= 0:
died.emit()

The stamina_regen_delay is critical for game feel. It means stamina doesn't start recovering immediately after an action - forcing the player to manage their resource window, not just spam attacks.

The Enemy Side

Everything above applies to enemies too. An enemy is the same architecture - state machine, stats component, hitbox/hurtbox - with AI-driven state transitions instead of player input.

CharacterBody3D (Enemy)
├── StateMachine
│   ├── IdleState      → wait, scan for player
│   ├── PatrolState    → walk between waypoints
│   ├── ChaseState     → pursue the player
│   ├── AttackState    → attack when in range
│   ├── CircleState    → strafe around the player
│   ├── StaggerState   → hit reaction
│   └── DeathState     → death animation, drop loot
├── NavigationAgent3D
├── DetectionArea
├── HitboxComponent
├── HurtboxComponent
└── StatsComponent

The shared architecture is the point. The hitbox/hurtbox system doesn't care if damage comes from a player or an enemy. The state machine pattern works the same way for both. Build the systems once, use them everywhere.

Where to Start

If you want to build a souls-like in Godot 4, don't try to build everything at once. Build systems in this order:

  1. Character movement - WASD, camera, basic animation
  2. State machine - Idle, walk, run states
  3. Combat - Single attack, then combos
  4. Dodge - Roll with i-frames
  5. Enemy AI - Patrol, chase, attack
  6. Hit detection - Hitbox/hurtbox with damage numbers
  7. Stats - Health, stamina, death
  8. Polish - Camera shake, hit freeze frames, particles

Each step builds on the last. Each one is testable on its own. And each one teaches you a pattern you'll use in every game you build after this.

We built an entire Godot 4 Soulslike Campaign that takes you through this exact progression - from a basic character controller to a complete boss fight with AI, combat, inventory, dialogue, and save/load. It's 7 courses and 100+ lessons of hands-on building. The first quest is completely free.

godottutorialsouls-likecombat