tinker/notes
Menu

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.

Mathieu Muller
  • #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_process runs 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_vector reads 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 * delta in 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.