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.
# 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 - 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.
# 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 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.
# 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 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.
# 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")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
Area3Dattached to the weapon. Enabled only during attack animations. - Hurtbox - An
Area3Don 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 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.
# 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 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:
- Character movement - WASD, camera, basic animation
- State machine - Idle, walk, run states
- Combat - Single attack, then combos
- Dodge - Roll with i-frames
- Enemy AI - Patrol, chase, attack
- Hit detection - Hitbox/hurtbox with damage numbers
- Stats - Health, stamina, death
- 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.