PIXEL QUEST! 🗡️Build Your Own Zelda-Style Adventure
Explore a top-down world, swing your sword at slimes, find a hidden chest, and unlock the exit! Your very own legend begins right here.
Explore a top-down world, swing your sword at slimes, find a hidden chest, and unlock the exit! Your very own legend begins right here.
Understand what we're building and get Godot 4 open and ready!
We're going to build a game like the Legend of Zelda, one of the most famous adventure games EVER! Your tiny hero will walk around a dungeon, swing a sword at slimes, find a chest with a key, and escape through a locked door. You're going to make every single piece of it. Let's go, hero! ⚔️
Episode 6 introduces top-down movement, no gravity at all this time. The hero moves in four directions using a velocity vector. New concepts across the 12 steps: 4-directional movement, sword as a temporary Area2D child, slime AI with velocity bouncing, chest state machine, key pickup, and a locked door that watches for a signal.
Create a new Godot 4 project called "Pixel Quest" and set it up.
Time to open Godot and start fresh! Every adventure begins somewhere, ours begins with a brand new project. Let's name it and get it ready!
Standard Godot 4 setup, Compatibility renderer. A square window works well for top-down: try 512×512 or 576×576 in Project Settings → Display → Window. This gives a classic Zelda dungeon-room feel.
Create a Hero scene that moves up, down, left, and right, no jumping, no gravity!
This time our hero doesn't jump at all! Instead of running and jumping, our Zelda hero walks in ALL FOUR directions, up, down, left, right. It's a completely different kind of movement, and it feels really cool!
Hero scene: CharacterBody2D → Sprite2D + CollisionShape2D (CircleShape2D works well for top-down). No gravity variable needed. Movement uses Input.get_vector("ui_left","ui_right","ui_up","ui_down") which returns a Vector2, multiply by SPEED. Add to "player" group. Save as hero.tscn.
get_vector() is the cleanest way to handle 4-directional movement in Godot 4, it handles diagonals gracefully and returns a properly normalised vector when two keys are held. No if/elif chains needed.extends CharacterBody2D const SPEED = 180 func _physics_process(delta): # get_vector gives us left/right/up/down as one Vector2 var dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") velocity = dir * SPEED move_and_slide()
get_vector reads the arrow keys, you multiply the direction by your SPEED constant, and move_and_slide actually moves the body.Create a Main scene with a TileMapLayer dungeon floor and solid walls the hero bumps into.
Our hero needs somewhere to explore! We're going to build a dungeon room, a floor made of tiles and thick walls all around the edge. It's like building a room out of blocks. You get to be the dungeon designer!
Main scene: Node2D root. Add a TileMapLayer with a TileSet containing a floor tile (passable) and a wall tile (with Physics Layer collision). Paint a full floor, then a border of wall tiles. Instance hero.tscn in the middle and add a Camera2D to the hero.
Make a Slime enemy that bounces around the dungeon room all by itself.
What's a dungeon without a monster?! We're going to make a little slime that bounces around the room on its own. It doesn't chase you, it just bounces. But if it touches you... look out! 🟢
Slime scene: CharacterBody2D → Sprite2D + CollisionShape2D. Script: a constant velocity (e.g. Vector2(120, 90)). In _physics_process call move_and_slide(), then check collisions and reflect velocity using the collision normal: velocity = velocity.bounce(normal). Add to "enemy" group.
velocity.bounce(normal) is a lovely one-liner that handles wall reflection perfectly. The asymmetric starting velocity (120,90 rather than 100,100) prevents the slime getting stuck bouncing perfectly horizontally, a classic gotcha.extends CharacterBody2D # Start moving at an angle so bouncing looks natural var velocity_dir = Vector2(120, 90) func _physics_process(delta): velocity = velocity_dir move_and_slide() # Bounce off anything we hit for i in get_slide_collision_count(): var col = get_slide_collision(i) velocity_dir = velocity_dir.bounce(col.get_normal())
bounce the velocity off the surface, using the collision's normal (the direction the wall faces).velocity.bounce(normal) do?Show three hearts on screen. When a slime touches the hero, they lose a heart!
In Zelda, the hero has HEARTS for health! We're going to show three hearts at the top of the screen. If a slime touches you, you lose a heart. Lose all three, game over! Guard those hearts! 💚💚💚
Add a CanvasLayer → Label showing ❤️❤️❤️ for the HUD. Track lives = 3 in the hero script. Add an Area2D (HurtBox) child of Hero. On body_entered with "enemy": decrement lives, update the label, add a brief invincibility timer (1s), then reload the scene if lives == 0.
is_hurt + await get_tree().create_timer(1.0).timeout achieves this cleanly.# ADD to hero.tscn script: @export var hearts_label: Label var lives = 3 var is_hurt = false func _on_hurt_box_body_entered(body): if body.is_in_group("enemy") and not is_hurt: is_hurt = true lives -= 1 _update_hearts() if lives <= 0: get_tree().reload_current_scene() # Invincibility frames, 1 second safe time await get_tree().create_timer(1.0).timeout is_hurt = false func _update_hearts(): var h = "" for i in lives: h += "❤️" hearts_label.text = h
Press Space to swing a sword that destroys slimes it touches!
HERE IT IS, THE SWORD! 🗡️ Press the Space bar and your hero swings their sword. If a slime is in the way, BONK! It's gone. This is the moment our game really feels like Zelda. Get ready to fight!
Add a child Area2D (Sword) to the Hero, with a small RectangleShape2D in front of the sprite. Default: monitoring = false. On Space press, enable it for 0.2s then disable. On body_entered, if the body is in "enemy", queue_free() the slime.
$Sword.monitoring = true/false to toggle the hitbox cleanly.# ADD to hero.tscn script: @onready var sword = $Sword var attacking = false func _physics_process(delta): # ... keep your existing movement code ... # Swing the sword on Space! if Input.is_action_just_pressed("ui_accept") and not attacking: _swing_sword() func _swing_sword(): attacking = true sword.monitoring = true # Turn the hitbox ON await get_tree().create_timer(0.2).timeout sword.monitoring = false # Turn it OFF again attacking = false func _on_sword_body_entered(body): if body.is_in_group("enemy"): body.queue_free() # Slime destroyed!
true turns the hitbox on. queue_free removes the slime from the game.Place a chest in the dungeon. When the hero touches it, it opens and gives a key!
Every Zelda game has a treasure chest hiding something important! We're going to make a chest that sits in the dungeon. When you walk up to it, it OPENS and gives you a key. That key opens a special locked door, but first, we need to find the chest! 📦
Chest scene: StaticBody2D → Sprite2D + CollisionShape2D (blocks movement) + Area2D (detect overlap). Two states: closed and open. On Area2D body_entered with "player": if closed, switch to open, set has_key = true on the hero, and emit a key_collected signal.
key_collected) from the Chest to the Door keeps the two objects loosely coupled, a great pattern to introduce even at this level. Connect it in main.tscn via the editor.extends StaticBody2D signal key_collected # Broadcast when opened! var is_open = false func _on_open_zone_body_entered(body): if body.is_in_group("player") and not is_open: is_open = true body.has_key = true # Give the hero the key $Sprite2D.modulate = Color.YELLOW # Glow to show it's open! key_collected.emit() # Tell the door!
if body.is_in_group("player") and not is_open before opening.not is_open part do?key_collected signal used for?Place a locked door that opens only when the hero has collected the key from the chest.
Now we use that key! We're going to put a locked door on one side of the dungeon. It's solid as a wall until you have the key, then it disappears and you can escape! Can you fight through the slimes and find the chest in time? 🔐
Door scene: StaticBody2D → Sprite2D + CollisionShape2D + Area2D (detect hero). On body_entered with "player": check body.has_key, if true, hide the sprite and disable the collision so the hero can walk through.
extends StaticBody2D var is_open = false func _on_detect_zone_body_entered(body): if body.is_in_group("player") and body.has_key and not is_open: is_open = true $Sprite2D.hide() # Door disappears! $CollisionShape2D.disabled = true # Hero can walk through
Place an exit zone, once the door is open, step through it to win!
The door is open, now we need something waiting on the other side! We'll put an invisible exit zone just outside the door. Step through the open door and into it, YOU WIN! You've escaped the dungeon! 🏆✨
Exit scene: Area2D → CollisionShape2D positioned just outside the door gap. On body_entered with "player": show a CanvasLayer "You Escaped! 🎉" win label. Same win-condition pattern used across the whole series.
extends Area2D @export var win_label: Label func _on_body_entered(body): if body.is_in_group("player"): win_label.show()
if body.is_in_group("player")?Place a potion in the dungeon, touching it restores one heart!
What if the slimes get you and you lose a heart? That's what potions are for! We're going to hide a little green bottle somewhere in the dungeon. Find it and touch it, a heart comes back! Healing magic! 🧪💚
Potion scene: Area2D → Sprite2D + CollisionShape2D. On body_entered with "player": if the hero's lives < 3, increment lives, call _update_hearts(), and queue_free() the potion. Place it where the player must navigate around slimes to reach it.
extends Area2D func _on_body_entered(body): if body.is_in_group("player"): # Only heal if not already at full health if body.lives < 3: body.lives += 1 body._update_hearts() queue_free() # Potion is used up!
queue_free removes the used-up potion.if body.lives < 3 before healing?Customise everything, make this YOUR quest!
YOUR DUNGEON! YOUR RULES! 🎉 Add more rooms, more slimes, more potions! Give your hero a name. Make the walls a spooky colour. Design secret passages! You've built a real top-down adventure game. You are officially a video game developer. Go wild! 🗡️⚔️🏰
Great stretch ideas: add facing direction to the sword (flip $Sword.position.x based on last horizontal input); add a second room using change_scene_to_file(); add an AnimationPlayer for a sword swing; add AudioStreamPlayer for sound effects; add a second enemy that follows the player using (player_pos - position).normalized() * speed.
velocity.x and flip $Sword.position.x)A top-down adventure game just like the original Legend of Zelda! Your hero walks in all four directions, fights bouncing slime enemies with a sword, collects a chest, and escapes through a locked door.
Have you ever played Zelda? It's a game where a little hero explores a world full of puzzles and monsters. We're going to make our OWN version with our own hero, our own slimes, and our own dungeon to explore. You'll even make a SWORD to fight with!
Episode 4 of My First Video Game. Biggest new concepts: top-down 4-directional movement (no gravity!), a sword hitbox using a short-lived Area2D, bouncing slime AI using velocity reflection, a chest with an open/close state, and a locked door that opens when a key is collected. All new patterns, no prior episodes required.
🛠️ Free In-House Dev Tools
Use these free browser tools alongside this workshop to create custom sprites, sounds, levels and colour schemes for your game. No installs. Free forever.