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.
# state.gdclass_name Stateextends Nodevar state_machine: StateMachinefunc enter() -> void: passfunc exit() -> void: passfunc physics_update(delta: float) -> void: pass# state_machine.gdclass_name StateMachineextends Node@export var initial_state: Statevar current_state: Statefunc _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.
# input_controller.gdclass_name InputControllerextends Nodevar buffered_action: StringName = &""var buffer_timer: float = 0.0const BUFFER_WINDOW: float = 0.2 # 200ms bufferfunc _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_WINDOWfunc consume_buffer() -> StringName: var action := buffered_action buffered_action = &"" buffer_timer = 0.0 return actionfunc _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.
# attack_state.gdextends Statevar combo_step: int = 0var can_chain: bool = falsevar 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) returnfunc _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 eventfunc open_chain_window() -> void: can_chain = true# Called by animation event at the end of the attackfunc 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.
# dodge_state.gdextends State@export var dodge_speed: float = 8.0@export var stamina_cost: float = 25.0var dodge_direction: Vector3var 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 eventfunc enable_iframes() -> void: is_invincible = true hurtbox.set_deferred("monitoring", false)# Called by animation eventfunc 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 eventfunc 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.
# hitbox_component.gdextends Area3D@export var damage: int = 20func _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.
# stats_component.gdextends Nodesignal 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.0var health: intvar stamina: floatvar regen_timer: float = 0.0func _ready() -> void: health = max_health stamina = max_staminafunc _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 truefunc 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:
- Character movement: WASD, camera, basic animation
- State machine: idle, walk, run
- Combat: one attack first, then the combo
- Dodge roll with i-frames
- Enemy AI: patrol, chase, attack
- Hit detection: hitboxes, hurtboxes, damage numbers
- Stats: health, stamina, death
- 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.


