BLOG
Learning Godot from Scratch: The Core Concepts (Building Delve, Part 1)
I write web software, not games. The core Godot 4 ideas everything else builds on — nodes, scenes, signals, physics, animation — learned by building a 3D dungeon crawler.
- #godot
- #gamedev
- #learning
- #build-log
I write web software — PHP, JavaScript, the usual. I know how to program. What I didn’t know was a game engine. So I’m learning Godot 4 the only way that ever sticks for me: by building a real thing and refusing to move on until I understand why each piece works.
The “real thing” is Delve, a top-down dungeon crawler. This post isn’t a walkthrough; it’s the set of mental models that finally made the engine click.
1. Everything is a Node. A Scene is a bag of Nodes.
This is the whole foundation, so it’s worth getting right.
A Node is a single thing that does one job. A Sprite2D draws a picture. A
Camera3D is a viewpoint. A CollisionShape3D describes a physical shape. There
are hundreds of node types and each is narrow on purpose.
A Scene is just a tree of nodes saved together. The trap most newcomers fall into (I did) is thinking “scene = level”. It doesn’t. A scene is any reusable bundle. My player is a scene. A coin is a scene. A whole dungeon is a scene. You build small scenes and compose them.
Here’s my player scene:
Player (CharacterBody3D) ← the physics body + the movement script
├── Model (character.glb) ← the visual: an animated 3D model
├── CollisionShape3D ← a capsule: the physical body (invisible)
└── Camera3D ← the viewpoint
The lesson that took a beat to land: the visual and the collision are
different nodes. The Model is just a picture of a person; the
CollisionShape3D is the actual capsule that bumps into walls. Keeping them
separate is what lets you make a hitbox slightly smaller than the sprite so the
game feels fair — a thing games do constantly.
Coming-from-web translation: a Scene is like a reusable component, and the node tree is its DOM. You compose components; here you compose scenes.
2. The hierarchy isn’t decoration — children inherit transforms
Notice the Camera3D is a child of the player. That’s not for tidiness. In
Godot, a child node’s position is relative to its parent. Because the camera
lives under the player, it moves with the player automatically. I never wrote
“make the camera follow the player” — the hierarchy is the follow logic.
This bites you too, though. When I made the character turn to face the direction
it walks, my first instinct was to rotate the player body. That spun the camera
with it and turned my clean top-down view into a tilt-a-whirl. The fix: rotate
only the Model child, leaving the body (and its camera) unrotated.
3. Movement: _physics_process, not _process
A CharacterBody3D is the physics body built for things you drive — it won’t
get shoved around on its own, but it stops at walls. The movement code is almost
nothing:
func _physics_process(_delta: float) -> void:
var input := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity.x = input.x * speed
velocity.z = input.y * speed
move_and_slide()
Three things I learned here:
_physics_processruns on a fixed clock (60×/sec), separate from rendering. Put movement here. If you move things in_process(the render loop), your game speed changes with frame rate — the #1 beginner bug.Input.get_vectorreads four inputs and returns a direction that’s already normalised, so diagonal movement isn’t faster than straight movement. Another classic bug, avoided for free.move_and_slide()does the actual moving and slides along walls instead of sticking. It applies the timestep internally, which is why there’s no* deltain sight.
One sign gotcha worth knowing up front: pressing “up” gives input.y = -1, and
in 3D -z points away from the camera into the screen — so mapping input.y → velocity.z makes up-on-screen move you up-the-map, as you’d expect. If yours
comes out inverted, that sign is the first thing to check.
ui_left/right/up/down are actions Godot ships with, pre-mapped to the arrow
keys — so this works with zero input configuration.
4. Signals: “something happened” without hard-wiring who cares
This is the pattern that runs half of all Godot gameplay, and it’s beautiful.
I needed a collectible. An Area3D is a trigger volume: it notices when a
body overlaps it but, unlike a wall, doesn’t block movement — perfect for
pickups. Area3D emits a built-in body_entered signal when something walks
into it. My coin connects that to its own handler, then emits its own custom
signal:
extends Area3D
signal collected # our own announcement
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D) -> void:
if not body.is_in_group("player"):
return
collected.emit() # shout it; don't care who listens
queue_free() # remove myself
Elsewhere, a game-manager script listens:
for coin in get_tree().get_nodes_in_group("coin"):
coin.collected.connect(_on_coin_collected)
The point: the coin doesn’t know the score exists. It just shouts “I was
collected!” and whoever subscribed reacts. Want to add a sound, a particle
burst, a second listener? You add them on the listening side and never touch
the coin. Emit on one end, connect on the other — that’s doors, damage,
enemies spotting you, level exits, all of it.
Web translation: it’s an event emitter / pub-sub, baked into every node.
5. Groups: tags instead of fragile name checks
Did you spot is_in_group("player") above? Groups are Godot’s tag system.
The player tags itself once:
func _ready() -> void:
add_to_group("player")
Now anything can ask “is this the player?” without caring about the node’s name
or class, and get_tree().get_nodes_in_group("coin") grabs every coin at once.
Cleaner and far less brittle than checking body.name == "Player".
(Bonus gotcha I hit: this works because child nodes run _ready() before their
parents. The coins add themselves to their group before the manager goes
looking for them.)
6. Collision is two nodes: the body and the shape
To stop the player walking through walls, a wall mesh isn’t enough — a mesh is
just a picture. I wrapped each wall in a StaticBody3D (an immovable solid)
holding two children: the visual model and a CollisionShape3D box.
StaticBody3D ← the solid
├── wall.glb ← the visual
└── CollisionShape3D ← a box: the actual barrier
The satisfying part: my player script didn’t change one character.
move_and_slide() already knows how to stop at any StaticBody3D. The moment
the colliders existed, the player respected them.
One efficiency habit worth forming early: all 40 of my walls share one
BoxShape3D resource. Shapes, meshes, and materials are resources you can
reuse across many nodes instead of duplicating.
7. Animation: skeletal clips, and the looping gotcha
My characters are Kenney models that arrive with a full rig —
idle, walk, attack-melee, die, and more — driven by an AnimationPlayer
node embedded in the model. Playing them is just:
anim.play("walk" if moving else "idle")
The gotcha that cost me a minute: animations imported from a .glb have
looping turned OFF by default. walk would play once and freeze. You flip the
locomotion clips to loop explicitly:
for clip in ["idle", "walk"]:
anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
One-shot clips like attack and die correctly stay single-play.
8. The part I didn’t expect to love: testing without playing
Here’s the thing that’s made building this fast. Replaying a game by hand to check a change is slow and easy to skip. Godot can run headless (no window), and a script can load the real game, fake some input through real physics frames, and assert the result. My wall test, abridged:
extends SceneTree
func _run() -> void:
var main := (load("res://scenes/main.tscn") as PackedScene).instantiate()
root.add_child(main)
await process_frame
var player := get_first_node_in_group("player") as CharacterBody3D
player.global_position = Vector3.ZERO
Input.action_press("ui_right") # hold Right for real
for i in 150: await physics_frame # ~2.5s of physics
Input.action_release("ui_right")
# East wall's inner face is at x = 4.5; the player should be stopped short.
assert(player.global_position.x < 4.5)
Run it with godot --headless --script tests/wall_test.gd and you get
pass/fail in a second:
PASS perimeter walls have colliders (expected 40), got 40
PASS player reached the wall (x > 4.0), got 4.28
PASS player blocked by wall (x < 4.5), got 4.28
The abridged snippet above uses bare assert to show the mechanism; the full
test wraps each check in a tiny helper that prints those labelled PASS lines,
so a failing run tells you which expectation broke, not just that one did.
4.28 is exactly the wall face (4.5) minus the player’s radius (0.22). The
collision is provably correct, and I never touched the keyboard.
What building it actually taught me
Two detours taught me more than the smooth parts:
- The assets dictate the dimension. I started in 2D. Then the art I wanted
turned out to be 3D models, and 3D models simply don’t plug into a 2D scene.
Rather than fight it, I went 3D — and the migration was painless precisely
because the concepts above (nodes, signals, groups, physics bodies) are the
same in 2D and 3D. Only the node names gain a
3D. - Design follows what the tools are good at. A coin-collector minigame was fine, but once I saw the asset kit had stairs, chests, traps, and rigged enemies, the honest game was a dungeon crawler. Letting the materials steer the design is not cheating; it’s how you ship.
That next step became Part 2: generating dungeons with a room graph — the dungeon generates itself now, assembling rooms, corridors, and props from the kit, proven correct headless before a single tile is drawn. That’s where this stops being a tutorial and starts being the thing I actually want to build.