Coding Quests
The Scroll Library
Tutorials

How to Make a Souls-Like Game in Godot 4

February 19, 2026Updated June 11, 20268 min read

Souls-like combat looks simple from the outside and then eats your whole month. Precise hits, stamina management, dodge rolls with i-frames, hit stagger, multi-hit combos, enemy AI that punishes greed without feeling cheap. The systems interlock tightly, and getting one wrong throws off the feel of everything else.

Godot 4 is more than capable of handling all of it. The node system, state machines, and built-in physics map naturally onto the layered architecture a souls-like demands. What kills most attempts isn't the engine. It's the architecture. So that's where we start.

The Architecture: Systems, Not Spaghetti

The biggest trap with a souls-like is trying to build everything in one script. I've made this mistake. You end up with a 2,000 line Player.gd that handles movement, combat, stamina, dodge, damage, death, and UI updates, all crammed into _physics_process. It works right up until the day you try to change anything.

What you want instead is small systems that talk 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 minds its own business. The StateMachine decides which behavior is active. The InputController buffers inputs. The StatsComponent tracks health and stamina. They talk through signals and shared references, never by reaching deep into another system's internals. Boring discipline, huge payoff.

The State Machine Is Non-Negotiable

If there's one pattern you need for a souls-like, it's the 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. No flag soup scattered across forty functions.

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 later, combos, dodge rolls, hit reactions, is just a new state node with clean, isolated logic. If the pattern is new to you, read the Godot 4 state machine tutorial first and come back. Everything below assumes it.

Input Buffering: Why Your Combat Feels Sluggish

The difference between tight combat and sluggish combat is usually input buffering, not animation speed. In a souls-like, the player presses attack during a roll and expects the attack to fire the instant the roll ends. Without buffering, that press just vanishes. The player won't think "I pressed too early." They'll think your game ate their input. And they're right.

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 looks for a buffered attack to chain combos. The dodge state checks at the end of the roll so the queued action fires the moment recovery ends.

This one system takes combat from "why didn't my button press register" to "this feels tight." The 0.2 second window in the code is a solid starting point. Go much past 0.3 and inputs fire so late the game starts playing itself.

Combo System: Chaining Attacks

A 3-hit combo works like this: each attack is a phase of the attack state. The state plays an animation, opens a window where the next input counts, and either chains into the next hit or drops back 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 directly in the animation timeline. Adjusting combo feel becomes an animation task instead of a code task. When hit two comes out too slow, you drag a marker instead of touching the script.

Dodge Rolling With i-Frames

The dodge roll is what makes souls-like combat tactical. The player commits to a roll, gains a short invincibility window (the i-frames), then eats recovery frames where they're vulnerable again. Commitment is the whole point.

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")

Animation events control the timing again. The i-frame window is wherever you place enable_iframes() and disable_iframes() in the dodge animation. At 60 fps, try 8 to 12 i-frames for a quick roll. Around 15 feels forgiving and arcadey. Under 6 feels hardcore. Tuning it is just dragging markers in the animation editor.

Hit Detection: Hitboxes and Hurtboxes

Souls-like combat runs on the hitbox and hurtbox pattern. The hitbox is an Area3D attached to the weapon, enabled only during attack animations. The hurtbox is 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 switches the hitbox on for a few frames during the swing, then off again. That's what makes hits feel fair: the dangerous part of an attack lasts exactly as long as the swing looks dangerous.

Stamina: The Decision Currency

Stamina is what turns button mashing into decision making. Attack, dodge, and sprint all cost stamina. Run dry at the wrong moment and you're standing in front of a boss with nothing. That tension is the genre.

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 the sneaky important number here. Stamina doesn't start recovering the instant an action ends, so the player has to manage a resource window instead of spamming. With a 25 stamina roll against a 100 point pool, the player gets four actions before they're empty. Start there, then push the numbers until panic-rolling stops being free.

The Enemy Side

Everything above applies to enemies too. An enemy is the same architecture, a state machine plus stats plus hitboxes and hurtboxes, with AI-driven 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 system doesn't care whether damage came from a player or an enemy. Build the systems once, use them on everything that fights. I cover the AI half in detail in the Godot 4 enemy AI tutorial if that's the piece you're stuck on.

Where to Start Building Your Souls-Like

Don't try to build all of this at once. Build it in this order:

  1. Character movement: WASD, camera, basic animation
  2. State machine: idle, walk, run
  3. Combat: one attack first, then the combo
  4. Dodge roll with i-frames
  5. Enemy AI: patrol, chase, attack
  6. Hit detection: hitboxes, hurtboxes, damage numbers
  7. Stats: health, stamina, death
  8. Polish: camera shake, hit freeze frames, particles

Each step builds on the last and is testable on its own. Realistically, steps 1 and 2 are a weekend. A first playable combat loop, where you fight one enemy and it actually feels okay, is more like three or four weeks of evenings. That's normal. Don't let week two convince you otherwise.

If you'd rather build it with guardrails, we made an entire Godot 4 Soulslike Campaign around this exact progression, from a basic character controller to a complete boss fight with AI, combat, inventory, dialogue, and save and load. It starts with the 3D Souls-Like Controller quest, runs 7 courses and over 100 lessons of hands-on building, and the first quest is completely free.

Either way, open Godot tonight and get a capsule rolling around a gray box. Every souls-like you've ever played started exactly there.

FAQ

Is Godot 4 good enough for a souls-like?

Yes, comfortably. CharacterBody3D, AnimationTree, Area3D hit detection, and NavigationAgent3D cover everything the genre needs, and Godot 4.x handles the scale most indie souls-likes run at: a few enemies, one arena at a time. Your bottleneck will be animation work and tuning, not the engine.

How long does it take to make a souls-like in Godot?

A solid combat slice (movement, camera, one weapon with a combo, dodge with i-frames, two or three enemy types) is one to three months of part-time work if you use bought or free animations. A full game is a multi-year project for a solo dev. Build the slice first and find out if you still love it.

Is GDScript fast enough for souls-like combat, or do I need C#?

GDScript is enough for the entire combat layer. Combat logic is event driven (animation events, signals, state transitions), nowhere near performance critical. If you ever need raw speed for something like hundreds of projectiles, move that one piece to C# later.

godottutorialsouls-likecombat

Stop reading. Start building.

Beat the demo boss by writing real Godot code, then build this for real in the 3D Roguelike Controller Quest.

Free, and no card needed. Built by a real person, with new quests every month.

Get the next Godot build in your inbox

New quests, project breakdowns, and game-dev tips. Free, no spam, unsubscribe anytime.

Written by Coding Quests

We teach Godot 4 by making you build complete systems: inventories, save systems, action roguelike controllers, enemy AI. The scrolls are free. The quests are where it sticks.