To make a roguelike in Godot 4, build seven systems in order. Start with the player controller, then the combat core, then enemies driven by a state machine. Next come rooms that lock until you clear them, loot and run upgrades, and a HUD with progress that survives death. Last, wire the loop that starts a run, ends it, and starts the next one. Each system only leans on the ones before it, so you can play-test after every step.
That's the order I used for The Last Forge, the 2D action roguelike that the CodingQuests 2D Roguelike campaign builds in Godot 4.7. Every code excerpt below comes from that project. I also built it in a free video course on the CodingQuests YouTube channel, with each lesson's code on GitHub under the MIT license. /roguelike maps that course chapter by chapter to the lesson where you write each system yourself.
Roguelike vs roguelite, in plain words
The most cited definition of a roguelike is the Berlin Interpretation, drafted at the International Roguelike Development Conference in 2008. Its high-value factors include random levels, permadeath, turn-based play, and a grid. Permadeath means death ends the run and you start over from the first level. Turn-based means nothing moves until you act.
A roguelite keeps random levels and permadeath, but it usually plays in real time, and some progress carries over between runs. By that test The Last Forge is a roguelite: real-time fights, random rooms, and a currency you keep after death. I still call it a roguelike here, since that's the broader word.
This guide covers action roguelikes: real-time, top-down, dash and swing. For a traditional turn-based roguelike, follow SelinaDev's free Yet Another Roguelike Tutorial instead. It's a Godot 4 series based on the r/roguelikedev Complete Roguelike Tutorial, from dungeon generation and field of view to saving and loading.
1. The controller: 8-direction movement, cursor aim, and a dash
Start with the player, because you test every later system by playing it. The player is a CharacterBody2D (Godot's node for a body you move from code) in Player.gd.
Movement reads four input actions into one vector, which gives you 8 directions. It normalizes the vector only when it's longer than 1. Without that, pressing up and right moves you about 41 percent faster. Aim is separate. A WeaponPivot node turns toward the cursor every frame, so you can walk left while swinging right.
The dash is where a roguelike starts to feel good. It lasts 0.14 seconds at 520 pixels per second, with a 0.6 second cooldown. During it the player has i-frames, short for invincibility frames: a window where hits don't count. The direction locks the moment you press the key.
# Player.gd (excerpt from the controller quest)func _start_dash() -> void: var input := _get_move_input() if input.length() > 0.1: _dash_dir = input.normalized() else: var to_mouse := get_global_mouse_position() - global_position _dash_dir = to_mouse.normalized() if to_mouse.length() > 0.01 else Vector2.RIGHT _is_dashing = true _dash_timer = dash_duration _dash_cd_timer = dash_cooldown is_invincible = true # i-frames: you cannot be hit mid-dashfunc _end_dash() -> void: _is_dashing = false is_invincible = falseWhile _is_dashing is true, _physics_process ignores steering and moves at dash_speed along _dash_dir. It also drops a fading afterimage every 0.025 seconds, which is most of why the dash reads on screen.
The Camera2D follows with smoothing and adds trauma-based shake. Trauma is a number from 0 to 1. Hits add to it, it drains at 1.4 per second, and the shake uses trauma squared. So quick hits barely wobble the view, while heavy hits kick it hard.
# PlayerCamera.gd (excerpt, the follow code trimmed)func add_trauma(amount: float) -> void: _trauma = clampf(_trauma + amount, 0.0, 1.0)func _process(delta: float) -> void: _time += delta _trauma = maxf(_trauma - trauma_decay * delta, 0.0) var shake := _trauma * _trauma offset = Vector2(max_offset * shake * _noise(_time * 30.0, 1.0), max_offset * shake * _noise(_time * 30.0, 13.0)) rotation_degrees = max_roll_degrees * shake * _noise(_time * 30.0, 27.0)One gotcha: a Camera2D ignores its own rotation unless you set ignore_rotation = false. My top-down movement tutorial covers movement in depth, and the top-down controller project is a free MIT download.
2. The combat core: hitboxes, hurtboxes, knockback, and hit-stop
Combat is three small components that the player and every enemy share:
HealthComponent: aNodewithdamage(),heal(), and adiedsignal.HitboxComponent: anArea2Dthat deals damage. It holdsdamageandknockback_forceand waits to be found.HurtboxComponent: anArea2Dthat receives damage. It detects the hitbox, hurts its siblingHealthnode, and knocks its body back.
Only the hurtbox listens for overlaps, so damage happens in exactly one place. That's also where the dash i-frames plug in: in the combat quest, the dash sets hurtbox.invulnerable = true.
# HurtboxComponent.gd (excerpt from the combat quest)func _on_area_entered(area: Area2D) -> void: if invulnerable: return if not area.is_in_group("hitbox"): return if area.has_method("try_register_hit"): if not area.try_register_hit(self): return var incoming: float = area.damage * damage_multiplier if health: health.damage(incoming) if _body.has_method("apply_knockback"): var dir: Vector2 = _body.global_position - area.global_position if dir.length() < 0.01: dir = Vector2.RIGHT _body.apply_knockback(dir.normalized(), area.knockback_force) hurt.emit(incoming, area.source_node if ("source_node" in area) else null)try_register_hit() is the hit-once rule. If an enemy leaves the hitbox and comes back during one swing, area_entered fires again, and without the rule that's a second hit from the same swing. The hitbox remembers who it hit and forgets with reset_hits() when the next swing starts. The weapon is its own small state machine (IDLE, WINDUP, ACTIVE, RECOVERY), and it only switches the hitbox on during ACTIVE.
Hit-stop means freezing the game for a few hundredths of a second when a hit lands. Your eye reads the pause as weight. The Last Forge freezes for 0.04 seconds on a quick hit and 0.07 on a heavy one.
# GameManager.gd (autoload, excerpt)func hit_stop(duration: float) -> void: if duration <= 0.0: return Engine.time_scale = 0.0 _hitstop_token += 1 var my_token := _hitstop_token var timer := get_tree().create_timer(duration, true, false, true) await timer.timeout if my_token == _hitstop_token: Engine.time_scale = 1.0The fourth argument of create_timer() makes the timer ignore time scale. Without it the timer never fires and the game stays frozen. The token lets only the newest hit-stop unfreeze the game. The full pattern is in my hitbox and hurtbox tutorial, with a free hitbox and hurtbox project.
3. Enemies and AI: one state machine, many enemy types
A state machine means the enemy is always in exactly one state, and each state has its own rules for when to switch. Every enemy extends EnemyBase, a CharacterBody2D with six states in an enum: IDLE, CHASE, TELEGRAPH, ATTACK, RECOVERY, and DEAD. The base owns health, hitboxes, knockback, and death. Each enemy type sets its own stats and overrides _ai_process(), its brain. Here's the Grunt:
# GruntEnemy.gd (excerpt, extends EnemyBase)func _ai_process(delta: float) -> void: if _cd > 0.0: _cd -= delta match state: State.IDLE, State.CHASE: velocity = dir_to_player() * speed if has_player() and distance_to_player() <= attack_range and _cd <= 0.0: _enter_telegraph() State.TELEGRAPH: velocity = velocity.move_toward(Vector2.ZERO, 700 * delta) state_timer -= delta if state_timer <= 0.0: _enter_attack() State.ATTACK: state_timer -= delta if state_timer <= 0.0: _enter_recovery() State.RECOVERY: velocity = velocity.move_toward(Vector2.ZERO, 700 * delta) state_timer -= delta if state_timer <= 0.0: state = State.CHASETELEGRAPH and RECOVERY are what make an enemy fair. A telegraph is a visible warning before an attack. The Grunt flashes a red circle for 0.35 seconds, swings for 0.14, then stands open for 0.45. You read the circle, dash through, and punish the recovery. Cut the telegraph and the same enemy feels cheap.
New enemy types are new numbers on the same skeleton. The enemies quest builds four:
- Grunt: walks at you, telegraphs, swings.
- Skitterling: fast and fragile, with a 0.18 second telegraph.
- Charger: locks its direction during a 0.5 second wind-up, then charges at 360 pixels per second until it hits a wall or its timer runs out.
- The Forge Warden: a boss that telegraphs a ground slam and a line charge, and enrages below half health. The finished project adds a ring of projectiles.
For more, read the 2D enemy AI tutorial, and the node-per-state version in my state machine tutorial. Both have free projects: enemy AI and state machine.
4. Rooms and the procedural dungeon: doors that lock until the room is clear
Procedural generation means the game builds levels from rules and dice rolls while it runs, instead of you placing every wall. It's why each run feels new.
The rooms quest builds a DungeonRoom on a TileMapLayer, Godot 4's node for painting a grid of tiles. It makes its own TileSet from an atlas in code, paints the floor and walls, adds wall collision, and places doors. It also scatters props, but never in the center, where the player spawns. Then a RoomController runs the fight: spawn a wave, lock the doors, count the enemies down, unlock at zero.
# RoomController.gd (excerpt from the rooms quest, types added)func _spawn_wave() -> void: var wave: Array[PackedScene] = [GRUNT, GRUNT, SKITTERLING, CHARGER] _alive = 0 for scene in wave: var enemy: EnemyBase = scene.instantiate() enemies.add_child(enemy) enemy.global_position = room.random_floor_point() enemy.defeated.connect(_on_enemy_defeated) _alive += 1 _lock_doors(true)func _on_enemy_defeated(_enemy: Node) -> void: _alive -= 1 if _alive <= 0: _on_cleared()func _on_cleared() -> void: _lock_doors(false) for door in room.doors: door.player_entered.connect(_go_to_next_room)func _lock_doors(locked: bool) -> void: for door in room.doors: door.set_locked(locked)Copy the count. Each enemy emits defeated once, from its die() function, and the room only listens to that signal. It never scans the scene tree for survivors. A Door has a solid gate that set_locked() toggles, and it emits player_entered when the player walks through it open. In the quest, that builds the next room. Every third room is an open room with no wave, and room 4 is the boss.
The finished project adds Dungeon.gd, a full-floor generator. It grows a random spanning tree across a 4 by 4 grid of room slots, then punches a 2-tile doorway through each wall that two connected rooms share. A spanning tree links every room with no loops, so no room is ever cut off. The first room is the entrance and the last is the boss. Some middle rooms become a shop, a treasure room, or an altar. A floor has 6 to 9 rooms.
Start with one room, though. A room that spawns, locks, and unlocks is already a playable game. My TileMapLayer tutorial covers the tile painting.
5. Loot and run modifiers: upgrades and corruption
A run modifier changes your character for this run only. Here that means upgrades. Each upgrade is a plain Dictionary, with its effect stored inside as a Callable: a function you can keep in a variable and call later.
# UpgradeManager.gd (excerpt, one upgrade shown)func _ready() -> void: _defs = [ {"id": "sharpened", "title": "Sharpened Edge", "rarity": "common", "desc": "+8 quick slash damage", "corruption": 5, "apply": _apply_sharpened}, ]func apply(player: Node, def: Dictionary) -> void: var apply_fn: Callable = def["apply"] apply_fn.call(player) player.add_corruption(float(def["corruption"])) player.add_item(def) player.on_stats_changed()func _apply_sharpened(p: Node) -> void: p.quick_bonus_damage += 8.0A new upgrade is one entry and one small function, and the upgrade card reads its text from the same dictionary. The finished project has 23 upgrades built this way.
Corruption is the price. It runs from 0 to 100. Every upgrade adds between 5 and 20, except Cleansing Light, which removes 25. Higher corruption makes you hit harder, but the damage you take climbs faster.
| Corruption | Tier | Damage you deal | Damage you take |
|---|---|---|---|
| 0 to 24 | Stable | x1.00 | x1.00 |
| 25 to 49 | Tainted | x1.05 | x1.06 |
| 50 to 74 | Corrupted | x1.12 | x1.18 |
| 75 to 100 | Overloaded | x1.22 | x1.40 |
The "take" multiplier goes straight into hurtbox.damage_multiplier, on the hurtbox from section 2. Corruption needed one line in the right place, not a rewrite. That's the payoff of building combat from components.
You pick one of three upgrade cards after each cleared room, so every clear is a greed decision. Health and gold drop as pickups along the way. In the finished project, corruption also spawns corrupted elites. Each enemy has a 16 percent chance at Tainted, 38 at Corrupted, and 60 at Overloaded. Elites have 1.6 times the health, move 25 percent faster, and hit 40 percent harder.
6. The HUD and meta progression: what resets and what persists
The HUD is a CanvasLayer, a layer drawn over the game that the camera doesn't move. It shows health, dash, and corruption bars, your gold, and how far you are from the boss. Every bar is a ratio: current value over max.
Meta progression is progress that survives death. The design decision is a hard line between run state and meta state:
- Resets every run: health, corruption, upgrades, gold, rooms cleared, and the combo.
- Survives death: the essence you bank when you die, and the permanent unlocks you buy with it in the Forge, the hub between runs.
You earn essence during a run and bank it when you die. An autoload is a script Godot keeps alive across scene changes. The GameManager autoload wipes run state in start_run(), and the MetaProgression autoload saves meta state with ConfigFile.
# GameManager.gd (autoload): run state, wiped at the start of every runfunc start_run() -> void: rooms_cleared = 0 run_essence = 0 gold = 0 combo = 0# MetaProgression.gd (autoload): meta state, saved to diskconst SAVE_PATH := "user://forge_save.cfg"func bank_run(essence: int) -> void: total_essence += essence save_game()func save_game() -> void: var cfg := ConfigFile.new() cfg.set_value("meta", "essence", total_essence) cfg.set_value("meta", "unlocks", unlocks) cfg.save(SAVE_PATH)user:// is the folder Godot can always write to, on every platform. Forge unlocks like Vigor (+25 max health) and Fleet Boots (+8 percent move speed) apply once, at the start of each run. More on saving in my Godot 4 save system tutorial.
7. The capstone loop: start a run, die, restart
The capstone quest is assembly, not new architecture. It adds a title screen, a pause menu, an AudioManager, and a Transitions autoload that fades to black on every scene change. Then it closes the loop: title, Forge, run, death, bank, retry.
# HUD.gd (excerpt from the capstone quest)func show_game_over() -> void: _game_over = true MetaProgression.bank_run(GameManager.run_essence) # bank + save what you earned game_over.visible = truefunc _on_retry_pressed() -> void: Engine.time_scale = 1.0 AudioManager.play("click") Transitions.reload_scene()Retry is a scene reload, so everything in the run scene (the player, the room, the enemies) comes back fresh for free. Autoloads don't reload. That's why MetaProgression lives in one, and why GameManager needs start_run().
Reset Engine.time_scale before you leave, too. Hit-stop sets it to zero and the death beat plays in slow motion. The time scale belongs to the engine, not the scene, so a reload won't reset it for you.
How to scope your first roguelike
The finished Last Forge has six enemy types, three weapons, 23 upgrades, item synergies, shops, and floors that keep going deeper. Your first roguelike doesn't need any of that. Copy the slice the campaign builds first:
- One weapon, with a quick attack and a heavy attack.
- Three enemy types and one boss.
- One room template, rebuilt at every door, with the boss in room 4.
- A handful of upgrades, each with a corruption cost.
- One currency you bank on death.
Make that fun, then add content. If a feature doesn't change a decision the player makes in a fight, it can wait.
Common roguelike mistakes in Godot 4
- Generating dungeons before the fight is fun. Random rooms full of a dull fight are still dull. Make one room with one enemy fun first.
- Damage every frame. Check overlaps every physics frame, or leave the hitbox on between swings, and one hit lands over and over. Here's the fix for a hitbox that damages every frame.
- A hit-stop that never ends. At
Engine.time_scale = 0.0, a normal timer never finishes. Passtrueas the fourth argument ofcreate_timer(). - Run state leaking into the next run. Autoloads survive a scene reload. If gold lives in one and nothing resets it, the next run starts rich.
- Adding physics nodes inside a physics callback. Enemies die inside a hurtbox signal, so loot dropped right there adds an
Area2Dwhile Godot is still handling physics events. The project usespickups.add_child.call_deferred(p)for exactly this.
FAQ
Is Godot 4 good for making a roguelike?
Yes. CharacterBody2D, Area2D hit detection, TileMapLayer, and ConfigFile cover everything a 2D action roguelike needs, and GDScript is fast enough for the combat logic. The Last Forge is a complete roguelike built in Godot 4.7 with no plugins.
What is the difference between a roguelike and a roguelite?
A traditional roguelike is turn-based and grid-based, with random levels and permadeath, and every run starts from zero. A roguelite keeps random levels and permadeath but usually plays in real time, and some progress carries over between runs, like a currency you spend on permanent unlocks.
How do I make a procedural dungeon in Godot 4?
Start with one room: a TileMapLayer that paints its floor and walls from code, places doors, and keeps obstacles away from the spawn point. Then connect rooms by growing a random spanning tree over a grid of room slots and punching a doorway through each shared wall, which guarantees every room is reachable. Lock each room's doors while its wave is alive.
How long does it take to make a roguelike in Godot?
A small first roguelike, with one room template, three enemies, and a handful of upgrades, is a realistic first project. The CodingQuests 2D Roguelike campaign is 7 quests, each estimated at 3 to 5 hours, so about 21 to 35 hours of guided building for the whole game.
Where can I learn to build a complete roguelike in Godot 4?
The CodingQuests 2D Roguelike campaign builds a complete action roguelike across 7 quests of 8 lessons each, from the controller to the full run loop, with every step checked in the browser. The first quest is free in full. For a turn-based roguelike, SelinaDev's free Yet Another Roguelike Tutorial covers the traditional style in Godot 4.
Your next step
Open the free Top-Down Controller quest and build the dash from section 1. All 8 lessons, the chest scripts, and the project download are free, and you don't need an account to start. You write real GDScript in the browser, and every step gets checked.
Then keep going:
- The 2D Roguelike campaign continues the same project. Combat Core opens its first 4 lessons free, and the other five quests open their first 3.
- /roguelike shows which quest and lesson matches each chapter of the video course.
- For 3D, the action roguelike campaign runs 11 quests, and my souls-like guide covers its architecture.
- The 12 free MIT projects at /resources include the controller, hitbox, enemy AI, and state machine systems from this guide.
A roguelike is that controller, run after run, with better reasons to dash each time. Start the Quest.


