Godot Engine 2D Tutorial: A Complete Small Game
A Godot engine 2D tutorial building one complete small game in Godot 4.7 with CharacterBody2D movement, coins, a countdown, best score, restart and web export.

This Godot engine 2D tutorial builds one complete small game, start to export, in Godot 4.7. The game is Coin Rush. A square moves in eight directions inside a walled room, coins appear every second, a 30 second timer runs down, and when it hits zero you see your score, the best score from earlier rounds, and a prompt to restart. Every line of GDScript is below and every API call was checked against the 4.7 class reference today.
Do the official Dodge the Creeps tutorial first if you have never opened the editor, and read the first Godot game guide on this site for the traps around it. It teaches scenes, signals and the inspector. This one assumes you know those words and adds the pieces that tutorial leaves out, which are CharacterBody2D movement with wall collision, a countdown you can read, a score that survives a restart, and a build that runs in a browser.
What You Are Building
The finished scene tree looks like this.
Main (Node2D)
├── Player (CharacterBody2D) instance of player.tscn
├── Walls (StaticBody2D)
│ ├── Top (CollisionShape2D)
│ ├── Bottom (CollisionShape2D)
│ ├── Left (CollisionShape2D)
│ └── Right (CollisionShape2D)
├── Coins (Node2D)
├── SpawnTimer (Timer)
├── RoundTimer (Timer)
└── HUD (CanvasLayer)
├── ScoreLabel (Label)
├── TimeLabel (Label)
└── GameOverLabel (Label)
Three scripts. One on the player, one on the coin, one on Main. Plus one autoload with a single variable. That is the whole project, and I'd resist adding anything until the export at the end works.
Project Settings First
Create a new project with the Compatibility renderer. That choice matters at the end, because Godot's web export docs say browser builds need WebGL 2.0, which is what the Compatibility method targets. Picking Forward+ now means changing it later.
Set the window to 640 by 480 under Project Settings, Display, Window. Then open the Input Map tab and add five actions. Bind them to whatever keys you like; the names are what the code uses.
| Action | Suggested keys |
|---|---|
| move_left | A, Left |
| move_right | D, Right |
| move_up | W, Up |
| move_down | S, Down |
| restart | Enter |
Adding these before writing code is the step I skip most often and regret most often. An action that does not exist fails silently in Input.get_vector(); the player just does not move and you spend ten minutes staring at a correct script.
The Player Scene
Create a new scene with a CharacterBody2D root named Player. Add a Sprite2D child with any 32 by 32 texture, or the Godot icon that ships in every project, and a CollisionShape2D child with a RectangleShape2D sized to match. Select the root and, in the Node dock's Groups tab, add it to a group called player. Save as player.tscn and attach this script.
extends CharacterBody2D
@export var speed: float = 220.0
func _ready() -> void:
motion_mode = CharacterBody2D.MOTION_MODE_FLOATING
func _physics_process(_delta: float) -> void:
var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = direction * speed
move_and_slide()
Three things in nine lines are worth knowing, and I'd read them before running it.
CharacterBody2D is, in the class reference's words, "a specialized class for physics bodies that are meant to be user-controlled. They are not affected by physics at all, but they affect other physics bodies in their path." That is the right node for a player. Gravity will not pull it, walls will still stop it.
MOTION_MODE_FLOATING is documented as "suitable for top-down games." The default, Grounded, treats the bottom of the screen as a floor and reacts to slopes, which is what you want for a platformer and not here. I set it in code so the choice is visible; the inspector works too.
velocity is in pixels per second and move_and_slide() applies it using the physics step. The docs flag the mistake everyone makes once, which is multiplying velocity by delta. Don't. The method does that for you, and it also slides along walls instead of stopping dead, which is why the movement will feel right without any extra code.
Input.get_vector() returns a vector whose length is limited to 1 with a circular deadzone, per the Input reference, so diagonal movement is not faster than straight movement. That bug is in a lot of beginner tutorials and this one line avoids it.
The Coin Scene
New scene, Area2D root named Coin, a Sprite2D child with a 16 by 16 texture, a CollisionShape2D child with a CircleShape2D. Save as coin.tscn and attach this script.
class_name Coin
extends Area2D
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()
Area2D is a detector, not a solid. The reference describes it as a region that "detects when other CollisionObject2Ds enter or exit it," and body_entered fires when a physics body, which our CharacterBody2D is, overlaps the shape. The docs note the signal requires monitoring to be true, which is the default, so leave it alone.
The group check is why the player joined a group earlier. Checking is_in_group("player") is more honest than checking the node's type, because a future enemy might also be a CharacterBody2D and should not collect coins.
class_name Coin matters for the next script. It lets Main treat an instanced coin as a Coin and connect to its collected signal with the type checker's approval, instead of hoping.
The Walls
In the main scene, add a StaticBody2D named Walls with four CollisionShape2D children, each with a RectangleShape2D. For a 640 by 480 window these positions and sizes box the play area with a 16 pixel border.
| Shape | Position | Size |
|---|---|---|
| Top | 320, 8 | 640 by 16 |
| Bottom | 320, 472 | 640 by 16 |
| Left | 8, 240 | 16 by 480 |
| Right | 632, 240 | 16 by 480 |
StaticBody2D does nothing but exist, which is what a wall should do, and the player's move_and_slide() handles the rest, sliding along the rectangle instead of stopping dead, so a diagonal push into a corner feels like sliding along a wall rather than hitting glue, without a single line of collision code on your side. If you would rather draw the room with tiles, that is a TileMapLayer with a physics layer on its tile set, and the docs mark the older TileMap node as deprecated, so use the layer node. For a first complete game, four rectangles are faster, and my preference is to keep the first version this dumb so that when something breaks there are only four places to look.
The Main Scene and Its Script
Add the remaining nodes from the tree above. Instance player.tscn and place it in the middle. Add an empty Node2D called Coins, two Timer nodes, and a CanvasLayer called HUD holding three Label nodes. For each label, enable Unique Name in Owner in the inspector so the script can reach them with %.
Before the script, one more setup step. Create game.gd with two lines and register it as an autoload named Game.
extends Node
var best_score := 0
The autoload docs describe these as objects that "are always loaded, no matter which scene is currently running" and can "store global variables such as player information." Register it under Project, Project Settings, Globals, Autoload. After that, Game.best_score is reachable from any script, and it survives when the main scene reloads, which is the whole reason it exists here.
Now the main script.
extends Node2D
const COIN_SCENE := preload("res://coin.tscn")
const ROUND_SECONDS := 30.0
const SPAWN_SECONDS := 1.0
const PLAY_AREA := Rect2(64, 64, 512, 352)
@onready var coins: Node2D = $Coins
@onready var spawn_timer: Timer = $SpawnTimer
@onready var round_timer: Timer = $RoundTimer
@onready var score_label: Label = %ScoreLabel
@onready var time_label: Label = %TimeLabel
@onready var game_over_label: Label = %GameOverLabel
var score := 0
var round_over := false
func _ready() -> void:
game_over_label.visible = false
score_label.text = "Coins: 0"
spawn_timer.wait_time = SPAWN_SECONDS
spawn_timer.timeout.connect(_spawn_coin)
round_timer.one_shot = true
round_timer.timeout.connect(_end_round)
round_timer.start(ROUND_SECONDS)
spawn_timer.start()
_spawn_coin()
func _process(_delta: float) -> void:
if round_over:
if Input.is_action_just_pressed("restart"):
get_tree().reload_current_scene()
return
time_label.text = "Time: %d" % ceili(round_timer.time_left)
func _spawn_coin() -> void:
if round_over:
return
var coin := COIN_SCENE.instantiate() as Coin
coin.position = Vector2(
randf_range(PLAY_AREA.position.x, PLAY_AREA.end.x),
randf_range(PLAY_AREA.position.y, PLAY_AREA.end.y)
)
coin.collected.connect(_on_coin_collected)
coins.add_child(coin)
func _on_coin_collected() -> void:
score += 1
score_label.text = "Coins: %d" % score
func _end_round() -> void:
round_over = true
spawn_timer.stop()
Game.best_score = maxi(Game.best_score, score)
game_over_label.text = "Time up. %d coins. Best %d. Press Enter to restart." % [score, Game.best_score]
game_over_label.visible = true
Run it. You should have a game.
If you don't, the failure table near the end lists the six things I'd check, in order.
Why the Timers Work the Way They Do
The Timer reference calls the node "a countdown timer" and "the simplest way to handle time-based logic in the engine," and three of its details carry this script.
start(time_sec) sets wait_time when the argument is greater than zero, which is why round_timer.start(ROUND_SECONDS) needs no separate wait time. one_shot on the round timer means it stops after firing once instead of restarting, so the round ends exactly once. And time_left is what the HUD reads every frame; the docs say it is always 0 when the timer is stopped, which is why the label is only updated while the round is running and would otherwise flash zero.
The spawn timer is the opposite shape. Not one-shot, so it repeats every second, and stopped by hand in _end_round() so coins stop appearing over the game-over text.
ceili() rounds the float up to an integer, so a timer at 29.2 seconds shows 30 rather than 29. That is a small choice and a deliberate one, because a countdown that reads 29 the instant you start feels like it cheated you.
Restart and the Best Score
get_tree().reload_current_scene() does what it says. The SceneTree docs describe it as replacing the current scene "with a new instance of its original PackedScene." Every node in Main is rebuilt, so score goes back to zero, coins are gone, both timers are fresh.
The autoload is not rebuilt. It lives outside the scene, which is why Game.best_score is still there after the reload and why the game-over label can show it. This is the smallest useful example of the singleton pattern the docs recommend for "persistent information between scenes," and it is the pattern you'll reach for again the moment your game has two scenes and a menu, which the Godot UI tutorial on this site builds next.
is_action_just_pressed() rather than is_action_pressed() for restart matters too. The docs say the "just" version returns true only on the frame the key went down. With the plain version, holding Enter for a moment would reload the scene several times.
Export It for the Browser
A game that only runs in the editor is not finished. Export it.
Install the export templates when the editor prompts you, add a Web preset under Project, Export, and export to an empty folder. The web export page lists the constraints you will hit, and they are worth reading once rather than discovering.
| Constraint from the docs | What it means for Coin Rush |
|---|---|
| Needs WebAssembly and WebGL 2.0 | the Compatibility renderer you chose at the start |
| Single-threaded export is the default since 4.3 | leave it; threads need special HTTP headers |
Threaded builds need Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp |
only if you turn threads on; the PWA option has a workaround |
Cannot run from file:// |
double-clicking index.html shows nothing; serve it or upload it |
| C# projects cannot export to the web in Godot 4 | this project is GDScript, so it can |
| Web audio uses samples by default since 4.3 | fine for a coin sound; audio effects are not available on samples |
The docs say the export works on itch.io, and the itch.io publishing preflight on this site covers the upload with the index.html at the archive root. That is the first place I'd put this game. Not because it is good. Because a URL you can send to one friend is the difference between a tutorial you did and a game you made.
What This Adds Over the Official Tutorial
Dodge the Creeps is excellent and I'm not competing with it. This is what you have after this page that you did not have after that one.
| Piece | Dodge the Creeps | Coin Rush |
|---|---|---|
| Player node | Area2D with manual clamping | CharacterBody2D with move_and_slide and real walls |
| Movement input | four is_action_pressed checks | one Input.get_vector call, no fast diagonals |
| Countdown | none | Timer read through time_left every frame |
| Restart | button and signal | reload_current_scene on one action |
| Data across restarts | none | autoload with a best score |
| Export | none | web build with the constraints listed |
The two approaches to the player are not right and wrong. Dodge the Creeps avoids physics bodies on purpose to keep the first lesson small. Once you want walls, the body is the tool, and the procedural generation article on this site takes exactly this player into a generated room.
When It Does Not Work
These are the failures I'd expect from this build, roughly in the order they happen.
| Symptom | Likely cause | Check |
|---|---|---|
| Player does not move | an action name in the Input Map does not match the script | spell all four movement actions exactly |
| Player moves at the wrong speed on slow machines | velocity multiplied by delta somewhere | the docs warn against it; remove the multiplication |
| Player walks through walls | the walls are not a StaticBody2D, or a shape has no RectangleShape2D resource | click each CollisionShape2D and confirm the Shape field is filled |
| Coins never disappear | the player is not in the player group, or the coin's CollisionShape2D is empty |
Node dock, Groups tab on the Player root |
Game is not declared |
the autoload is not registered, or has a different name | Project Settings, Globals, Autoload |
| Timer label stuck at 0 | the round timer never started, or the label updates after round_over |
confirm start(ROUND_SECONDS) runs in _ready() |
| Web export shows a blank page | opened from file://, or a threaded build without the headers |
serve over HTTP, or use the single-threaded default |
The Questions People Ask About Godot and 2D
Is Godot good for 2D. Yes, and this is the least controversial thing on the page. The 2D section of the docs opens with "Godot includes a dedicated 2D renderer and 2D physics engine, as well as 2D-specific features like tilemaps, particles, and animation systems." Pixels are pixels in Godot 2D, not a 3D scene with a flat camera, and in my experience that shows up in how simple the walls above were.
Is GDScript easy to learn. The FAQ says it "was built from the ground up to maximize Godot's potential in the least amount of code," and the three scripts above total under 80 lines for a working game. My honest take is that the language is the easy part and the scene tree is the part that takes a month to feel natural.
Is Godot written in C or C++. The FAQ states the editor is written in C++, and the contributor docs have a whole section on custom modules in C++. The officially supported scripting languages, per the same FAQ, are GDScript, C# and C++.
What are the downsides. Two I can source and one I'll label as opinion. C# projects cannot export to the web in Godot 4, per the export docs. There has been no built-in visual scripting since 4.0 was announced in 2022, so a no-code route does not exist. And the opinion, the third-party asset and plugin ecosystem is smaller than the one around older engines, which you will notice the first time you go looking for a ready-made system instead of building it.
Is it good for beginners. It is free under the MIT license, the docs are good enough that this whole page leans on them, and the game above fits on one page. Build it, export it, send the link to one person, then come back and add a sound. That order, not the reverse.
Related Articles

How to Get Into Game Development Without a Degree
Get into game development without a degree using a $0 tool stack with licenses checked, a 12-week plan, a four-rung portfolio ladder and the failures to expect.

No Coding Game Engine: One Game Spec, Five Engines
Pick a no coding game engine by checking one small game spec against GDevelop, Construct 3, GameMaker, Unreal and Godot, using official docs and free-tier caps.

Procedural Generation in a Game Without Losing Design
Add procedural generation to a game without losing design control. A seeded Godot 4 room generator, a solvability check, fixed anchors and failures to test.