Building a 2D game in Godot with GDScript

Building a 2D game in Godot with GDScript

The best way to learn an engine is to finish a small game, not to read forever. So in this post we build a complete, if tiny, 2D game in Godot 4. A player moves around, collects coins to raise a score, and dies if an enemy touches them. By the end you will have touched every system you need for most 2D projects. If you have never opened Godot before, read my getting started with the Godot 4 game engine guide first, because I assume you know what a node and a scene are here.

Setting up the project and the player scene

Create a new project with the Forward Plus renderer. The first scene we build is the player. Add a CharacterBody2D as the root, then give it three children: a Sprite2D for the art, a CollisionShape2D for the physics body, and a Camera2D so the view follows the player. Set the collision shape to a capsule or a rectangle that roughly matches your sprite. Save the scene as player.tscn.

CharacterBody2D is the right base here because it gives us a built in velocity and a move_and_slide method that handles collision response without us doing the vector math by hand.

Moving the player

Before we write movement code, define some input actions. Open Project Settings, go to the Input Map tab, and add four actions named move_up, move_down, move_left, and move_right. Bind each to the arrow keys and the WASD keys. Mapping inputs to named actions instead of hardcoding keys means you can support gamepads and remapping later without rewriting anything.

Now attach a script to the player root. Here is the movement logic:

extends CharacterBody2D

# Pixels per second
@export var speed: float = 220.0

func _physics_process(delta: float) -> void:
    # Build a direction vector from the four input actions.
    # get_axis returns a value from -1 to 1 for each pair.
    var direction := Vector2.ZERO
    direction.x = Input.get_axis("move_left", "move_right")
    direction.y = Input.get_axis("move_up", "move_down")

    # Normalize so diagonal movement is not faster
    if direction.length() > 1.0:
        direction = direction.normalized()

    velocity = direction * speed
    move_and_slide()

A few things worth calling out. We use _physics_process instead of _process because anything that moves a physics body should run on the fixed physics timestep. The @export annotation makes speed editable in the Inspector, so I can tune it without touching code. And normalizing the direction stops the classic bug where moving diagonally is about forty percent faster than moving straight.

Making a collectible coin

Create a second scene with an Area2D as the root. Area2D detects overlaps without physically blocking anything, which is exactly what a pickup needs. Give it a Sprite2D and a CollisionShape2D. Save it as coin.tscn and attach this script:

extends Area2D

# Fired when the player grabs this coin
signal collected

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        collected.emit()
        queue_free()

We declare our own signal called collected and emit it when a player walks in. The coin does not know what a score is, and it should not. It simply announces that it was picked up and removes itself. To make the group check work, select the player node, open the Node dock next to the Inspector, switch to Groups, and add it to a group named player.

Tracking the score with a global

The score needs to outlive any single scene, so register it as an autoload. Create a script called game_state.gd:

extends Node

var score: int = 0

signal score_changed(new_score: int)

func add_points(amount: int) -> void:
    score += amount
    score_changed.emit(score)

func reset() -> void:
    score = 0
    score_changed.emit(score)

Register it in Project Settings under the Globals tab with the name GameState. Now any script in the game can call GameState.add_points and listen for the score_changed signal to update the display. This is the cleanest way to share state in Godot without tangling your scenes together.

Wiring up the level and the UI

Build a level scene. Drop in an instance of the player, scatter several coin instances around, and add a CanvasLayer with a Label inside it for the score. The CanvasLayer keeps the label pinned to the screen even as the camera follows the player. Attach a small script to the label:

extends Label

func _ready() -> void:
    GameState.score_changed.connect(_on_score_changed)
    _on_score_changed(GameState.score)

func _on_score_changed(new_score: int) -> void:
    text = "Score: " + str(new_score)

For each coin to actually add to the score, connect its collected signal to a handler in the level script that calls GameState.add_points(10). You can do this in the editor by selecting a coin and using the Node dock, or in code by looping over the coins in _ready. I prefer code when there are many instances, because connecting fifty coins by hand in the editor is tedious and error prone.

Adding an enemy and a game over

An enemy can be as simple as another Area2D that moves back and forth. When it overlaps the player, the game ends. Here is a bare bones patroller:

extends Area2D

@export var move_speed: float = 90.0
var _direction: int = 1

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _physics_process(delta: float) -> void:
    position.x += move_speed * _direction * delta
    # Flip direction at the patrol edges
    if position.x > 600 or position.x < 200:
        _direction *= -1

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        get_tree().change_scene_to_file("res://scenes/game_over.tscn")

The change_scene_to_file call tears down the current scene and loads a new one. Build a simple game over scene with a Label and a button that resets the score and loads the level again. Remember to call GameState.reset on that button so the next run starts clean.

Polish that punches above its weight

A working game and a game that feels good are different things. The cheapest wins I know of:

  • Add a short sound on coin pickup. Even a single beep makes collecting feel real.
  • Give the camera a small smoothing value so it eases toward the player instead of snapping.
  • Play a quick scale tween on the coin before it disappears so it pops rather than vanishes.
  • Add a subtle screen shake when the player dies. Twenty minutes of work, huge perceived quality.

Tweens in Godot 4 are created with create_tween and are great for this kind of juice without writing per frame animation code.

Where this scales to

You now have the full loop: input, movement, collision, collectibles, global state, an enemy, and scene transitions. Almost every 2D game is a more elaborate version of these same pieces. A platformer adds gravity and jumping to the movement script. A shooter spawns bullet scenes on a timer. A puzzle game swaps the physics for a grid. The architecture you just built carries over directly.

Finish this game, then break it on purpose. Add a second enemy type, a high score that persists to disk, a title screen. Shipping small and iterating is how you actually learn the engine, far more than any tutorial including this one.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *