Skip to content

Chapter 4: Programming the Game

Systems, signals, state: that is the whole game.

Chapters 2 and 3 were about decisions: what the player should feel, how the level should teach, what the hero looks like against the background. This chapter is about making a computer carry those decisions out sixty times a second. That means code, and code is where most beginner game projects quietly die. Not because programming is impossibly hard, but because nearly everyone’s first player script grows into a 600-line tangle where the jump bug and the pause menu are somehow connected.

Gameplay programmers avoid that tangle with a few habits. They split the game into systems that each do one job. They let those systems talk through messages instead of reaching into each other. And they treat every object as being in exactly one state at a time, so a character can never be dead and jumping in the same frame. Those three ideas, systems, signals and state, are the spine of this chapter.

We build in Godot 4 with GDScript because they are free, open, and honest about what an engine is doing, and we note where the same idea appears in Unity and Unreal, because concepts move between engines far more easily than syntax does. Every code block here is short on purpose: read it, type it, break it, fix it.


Before writing any code, decide what the pieces are. In a well-built game, the player, each enemy, each coin, the score display and the level itself are separate objects with clear jobs. That separation lets a team of forty programmers share one game without colliding, and lets a solo developer fix the jump without breaking the shop.

In Godot, everything in the game world is a node: a sprite, a collision shape, a timer, a camera. A group of nodes saved together is a scene, stored as a .tscn file. A player scene might be a CharacterBody2D root with a Sprite2D, a CollisionShape2D and a script; a coin scene might be an Area2D with a sprite and a shape.

A scene can be placed inside another scene as many times as you want. Forty coins in a level are forty instances of coin.tscn. Edit the source scene, and every instance updates. This is the same relationship as a class and its objects in object-oriented programming. Unity calls the equivalent a prefab; Unreal calls it a Blueprint class.

A useful rule: if you might need more than one, or want to test it alone, make it its own scene.

Scene Root node Why it stands alone
player.tscn CharacterBody2D Tested in an empty level before any enemies exist
slime.tscn CharacterBody2D Placed twenty times across five levels
coin.tscn Area2D Hundreds of instances; one place to fix the sound
hud.tscn CanvasLayer Reused by every level and the pause menu
level_1.tscn Node2D Holds instances of all of the above

Inheritance builds a family tree of types: Enemy, then FlyingEnemy, then FlyingShootingEnemy. It works until you need a walking enemy that shoots, and the tree has no branch for it. Most game code today prefers compositioncomposition: Building an object's behavior by attaching small, independent parts to it, rather than by inheriting everything from a parent type.: an object is assembled from parts, and each part is small enough to understand alone.

In Godot, the parts are child nodes. An enemy might be a body plus a Health node, a Hurtbox area, a Patrol node and a Shooter node. Want a shooting bat? Add the Shooter node. Want a peaceful one? Remove it. Unity is built entirely around this idea, with Components attached to GameObjects, and Unreal’s ActorComponents do the same job.

You will still write extends CharacterBody2D at the top of scripts, and that is inheritance. The guideline is simple: use inheritance for “is a” (a player is a physics body) and composition for “has a” (a player has health, has a weapon).

A state machinestate machine: A model in which an object is always in exactly one of a fixed set of states, and moves between them only through defined transitions. is the single most useful pattern in gameplay code. A player might be IDLE, RUN, JUMP, FALL or HURT. An enemy might be PATROL, CHASE or ATTACK. A door might be LOCKED, CLOSED or OPEN.

The payoff is that impossible combinations cannot happen. If the jump code only runs in the JUMP state and the death animation moves you into DEAD, then a dead player can never jump, and you never have to write the check that prevents it. In GDScript, a small state machine is an enum and a match:

enum State { IDLE, RUN, JUMP, FALL }
var state := State.IDLE
func _physics_process(delta):
match state:
State.IDLE:
if velocity.x != 0.0:
state = State.RUN
State.RUN:
if velocity.x == 0.0:
state = State.IDLE
State.JUMP:
if velocity.y > 0.0:
state = State.FALL

Larger games give each state its own script, but the shape is the same. Sketch states as circles and transitions as arrows before you write a line of code.


GDScript is Godot’s built-in language: Python-like indentation, designed around nodes and scenes, and understood by the editor well enough to autocomplete node paths and catch mistakes as you type. Godot also supports C#, which is the language of Unity; Unreal uses C++ and its visual Blueprints. Learn one of these well and the others come quickly, because the ideas below appear in all of them.

extends Node2D
var health = 3
var lives: int = 3
var speed := 200.0
const MAX_HEALTH := 5
@export var jump_force := 400.0
@onready var sprite = $Sprite2D

health is dynamically typed: it can hold anything, which is flexible and error-prone. lives: int is statically typed: the editor will refuse to store a string in it, and typed code runs faster. speed := 200.0 infers the type from the value, so it is a float forever. const never changes. The @export annotation puts the variable in the Inspector panel, where a designer can tune jump force on every enemy without opening a script. @onready waits until the node is inside the scene tree before grabbing a child, which prevents one of the most common crashes in Godot: asking for a node that does not exist yet.

The core types are int, float, bool, String, Vector2 (an x and y pair for positions and velocities), Array and Dictionary, plus every node class in the engine.

Functions are declared with func, and can declare their parameter and return types:

func take_damage(amount: int) -> void:
health -= amount
if health <= 0:
die()
func _ready():
print("Player entered the tree")

Functions whose names start with an underscore, like _ready, are callbacks: you write them, and the engine calls them at specific moments.

Callback When Godot calls it Put here
_ready() Once, when the node enters the scene tree Setup, connecting signals
_process(delta) Every rendered frame Camera, visual effects, UI
_physics_process(delta) At a fixed rate, 60 times per second by default Movement, collisions, gameplay
_input(event) Whenever an input event arrives Menus, one-off key handling

The difference between the two process functions matters. On a 144 Hz monitor, _process runs 144 times a second; _physics_process still runs 60 times, in step with the physics engine. Movement and collision code belongs in _physics_process so the game behaves identically on every machine. This is the fixed versus variable timestep distinction from Chapter 1, now with names attached. In both functions, delta is the time since the previous call in seconds. Multiply speeds by it and your values are “per second” rather than “per frame.”

A signalsignal: A message a node emits when something happens, which any other node can subscribe to; the emitter does not need to know who is listening. is how Godot objects talk without depending on each other. When something happens, a node emits a signal; any node that connected to it gets called. Buttons emit pressed, timers emit timeout, areas emit body_entered. You can declare your own:

signal died
signal health_changed(new_value: int)
func take_damage(amount: int) -> void:
health -= amount
health_changed.emit(health)
if health <= 0:
died.emit()

Somewhere else, the HUD connects: player.health_changed.connect(update_hearts). Now the player script has no idea a HUD exists. Delete the HUD, add three more listeners, or hook the signal to a sound player, and the player code never changes. Godot developers summarize the rule as “call down, signal up”: a parent may call functions on its children, but a child should report upward with signals. Unity’s C# events and Unreal’s event dispatchers are the same pattern with different names.


The physics engine is the part of Godot that decides whether two things overlap and what happens when they do. Most bugs in a beginner’s platformer are physics bugs, and most come from using the wrong body type or the wrong layer. This section fixes both.

Every solid or detectable object in 2D is one of four node types, and each one needs at least one CollisionShape2D child to give it a shape.

Node Moves how Use it for
StaticBody2D Never (or only when you move it in code) Floors, walls, platforms
RigidBody2D The physics engine moves it with forces and gravity Crates, balls, debris
CharacterBody2D Your code sets its velocity; the engine handles collisions Players, enemies, anything with a brain
Area2D Does not collide at all; only detects overlap Coins, checkpoints, damage zones, hurtboxes

The shape matters too. A capsule is the standard choice for characters because its rounded ends slide over the seams between tiles instead of catching on them. Rectangles suit platforms, circles suit projectiles.

Every physics bodyphysics body: A node the physics engine tracks for movement or collision; in Godot 2D the main types are StaticBody2D, RigidBody2D, CharacterBody2D and Area2D. that moves has a velocity: a Vector2 measured in pixels per second. Gravity is an acceleration, in pixels per second squared, so it must be multiplied by delta each physics step. Godot 4’s default 2D gravity is 980. Remember that in screen coordinates y grows downward, so “up” is negative.

extends CharacterBody2D
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
@export var speed := 200.0
@export var jump_velocity := -400.0
func _physics_process(delta):
if not is_on_floor():
velocity.y += gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
velocity.x = Input.get_axis("move_left", "move_right") * speed
move_and_slide()

move_and_slide() moves the body by velocity multiplied by delta (you do not multiply that yourself in Godot 4), slides it along any surface it hits, stops it at walls, and updates is_on_floor() for the next frame. Gravity still needs the explicit * delta because it is changing the velocity, not applying it.

You can now tune a jump with arithmetic instead of guesswork. With gravity 980 and a jump velocity of 400, the character rises for about 0.41 seconds and peaks about 82 pixels up. If your design calls for a 96-pixel jump that peaks in 0.35 seconds, the physics formulas give gravity of roughly 1,567 and a jump velocity of about 549. Many platformers also use stronger gravity on the way down, because a fast fall feels more responsive.

Godot’s 2D physics has 32 numbered layers, and every physics node has two settings. Its collision layercollision layer: The numbered category a physics object belongs to; paired with a collision mask, which lists the categories that object detects or collides with. says what it is; its collision mask says what it looks for. Two objects interact only if one’s mask includes the other’s layer. Name the layers in Project Settings so you read “Player” instead of 2.

Object Layer (what it is) Mask (what it notices)
Ground tiles (StaticBody2D) 1: World none
Player body (CharacterBody2D) 2: Player 1: World
Slime body (CharacterBody2D) 3: Enemies 1: World
Slime hurtbox (Area2D) 3: Enemies 2: Player
Coin (Area2D) 4: Pickups 2: Player

With that table, player and enemies walk on the world, coins notice only the player, and the hurtbox reports when the player touches the slime. The coin’s script is three lines: on body_entered, emit a collected signal, then call queue_free() to remove itself.


A moving rectangle is not a character. This section turns the physics from 4.3 into a player who feels good to control and an enemy who seems to think.

Never write code that checks a specific key. Open Project Settings, go to the Input Map tab, and define actions: move_left, move_right, jump, attack. Bind each one to a keyboard key and a controller button. Your code then asks about actions, not hardware. That is why keyboard and controller work at once, and why remapping controls for accessibility (a baseline feature in Chapter 3) becomes a menu that edits the map rather than a rewrite of your game.

Four functions cover almost everything. Input.is_action_pressed() is true while a button is held. Input.is_action_just_pressed() is true only on the frame it went down, which is what a jump needs. Input.is_action_just_released() fires once on release. Input.get_axis() turns two actions into a single value from negative one to positive one, and Input.get_vector() does the same for four actions in 2D.

The 4.3 script moves the player correctly, and it feels terrible: instant full speed and a dead stop read as robotic. Three small additions make most of the difference.

First, acceleration and friction: instead of assigning velocity.x directly, use move_toward() so it eases up to speed and eases back down. Second, variable jump height: if the player releases the jump button while still rising, cut the upward velocity, so a tap gives a short hop and a hold gives a full jump. Third, coyote timecoyote time: A short grace period, often around a tenth of a second, during which a player who has just walked off a ledge can still jump.: a short grace window after walking off a ledge during which a jump still counts. It is named for the cartoon coyote who runs past the cliff edge, and Celeste is the usual example. Players only notice it when it is missing, and then they blame the controls.

@export var coyote_time := 0.1
var coyote_timer := 0.0
func _physics_process(delta):
if is_on_floor():
coyote_timer = coyote_time
else:
coyote_timer -= delta
velocity.y += gravity * delta
if Input.is_action_just_pressed("jump") and coyote_timer > 0.0:
velocity.y = jump_velocity
coyote_timer = 0.0
if Input.is_action_just_released("jump") and velocity.y < 0.0:
velocity.y *= 0.5
move_and_slide()

A tenth of a second is six physics frames. The mirror-image trick, a jump buffer, remembers a jump press for about the same window so that pressing slightly before landing still works. Together they are the difference between “tight” and “unresponsive” in a review.

Enemy “AI” in most games is not machine learning. It is a state machine plus a few sensors, the same technique that drove the ghosts in 1980’s Pac-Man, where each ghost simply targets a different tile relative to the player.

A patrolling slime needs two sensors: a RayCast2D pointing down just ahead of its feet, to detect the edge of a platform, and the body’s own is_on_wall() check. A chasing slime needs one more: an Area2D sight zone whose body_entered signal sets the target and flips the state to CHASE, and whose body_exited flips it back.

enum State { PATROL, CHASE }
var state := State.PATROL
var direction := 1
var target: Node2D = null
func _physics_process(delta):
velocity.y += gravity * delta
match state:
State.PATROL:
if not $FloorRay.is_colliding() or is_on_wall():
direction *= -1
velocity.x = direction * speed
State.CHASE:
velocity.x = sign(target.global_position.x - global_position.x) * speed
move_and_slide()

When direction flips, move the ray to the other side ($FloorRay.position.x = 12 * direction) and flip the sprite with flip_h. Add an ATTACK state with a cooldown and you have a complete enemy. The chase is one sign() call; the intelligence lives in the transitions, not the math.


Every programmer spends more time finding out why code does not work than writing it. That is not a beginner phase; it is the job. The skill is doing it methodically instead of changing random lines and hoping.

When Godot hits a runtime error, the game pauses and the Debugger panel shows the message, the script, the line number and the stack of function calls that led there. Read all of it. “Invalid get index ‘position’ (on base: ‘Nil’)” means you asked a node that does not exist for its position: the $Path is wrong, the node was freed, or you asked before _ready. “Nonexistent function” usually means a typo or the wrong node type.

Symptom Likely cause Fix
Player falls through the floor Floor has no shape, or masks do not overlap Add CollisionShape2D; check layer and mask
Jump repeats while the button is held is_action_pressed instead of is_action_just_pressed Use the just_pressed version
Game runs faster on a better PC Movement in _process without delta Move it to _physics_process
Signal never fires Not connected, or connected to a freed node Check the Node tab; connect in _ready
Enemy walks off ledges RayCast2D disabled or pointing the wrong way Tick Enabled; check the target position
Crash on scene load $Child used before the node exists Use @onready

print() is the fastest debugging tool that exists, and professionals use it constantly. print_debug() adds the file and line, and push_warning() and push_error() write to the Errors tab so they are not lost in the scroll. When printing is not enough, set a breakpointbreakpoint: A marker on a line of code that pauses the running program there so you can inspect every variable and step forward one line at a time. by clicking the gutter next to a line (or writing the breakpoint keyword). The game freezes there, the Stack Variables panel shows every value in scope, and you can step forward a line at a time. The Remote tab of the Scene dock shows the live scene tree while the game runs, so you can click the player and see its actual position and velocity rather than what you assumed.

The method is the same every time: write down what you expected, observe what actually happened, form one hypothesis, and test one thing. The computer did exactly what the code said; the bug is the gap between the code and your intent.

“Optimize” does not mean “make everything faster.” It means finding the one thing that is eating the frame budget from Chapter 1, the 16.7 milliseconds at 60 FPS, and fixing that. Godot’s profilerprofiler: A tool that measures how much time each function and engine system takes per frame, so you can see exactly where a slow frame is going. lives in the Debugger panel. Start it, play the slow part of the game, and it lists every function with the milliseconds it consumed per frame. The Monitors tab graphs FPS, draw calls, physics time and memory over time.

Only then change something. The usual wins are unglamorous: cache node lookups with @onready instead of calling $Node inside _process; reuse projectiles from a pool instead of instantiating and freeing them every shot; free things that leave the screen with a VisibleOnScreenNotifier2D; give distant enemies a slower think rate. If the profiler says the time is in rendering, rewriting the enemy AI will change nothing. Donald Knuth’s 1974 line still hangs on studio walls: premature optimization is the root of all evil.


Chapter 4 Activity: The Playable Slice 🕹️

Section titled “Chapter 4 Activity: The Playable Slice 🕹️”

You will build a single-screen platformer slice in Godot 4: one player, one patrolling enemy, one pickup, and a score display, all communicating with signals. Keep a bug log as you go. The goal is not polish; it is a small system where every part has one job and you understand why each line exists. Use placeholder rectangles for art, and bring in your Chapter 3 assets only after everything works.

On paper, list every scene in your slice and its root node type. For the player and the enemy, draw a state diagram: circles for states, arrows labeled with the condition that causes each transition. Then write the collision layer and mask table for every physics node, using named layers. Finally, list every signal that will cross between scenes (for example, coin.collected to the HUD) and which scene listens to it. Do not open Godot until this page is complete.

Create the Input Map actions, then build the player scene with the 4.3 script. Play it and write one sentence about how it feels. Add acceleration with move_toward(), variable jump height, and coyote time from 4.4, testing after each addition. Record the gravity and jump velocity you settled on, and use the formulas from 4.3 to calculate the jump height and time to apex they produce. Compare that number with what you see on screen.

Build the enemy scene with a PATROL state that turns at edges and walls, and a CHASE state triggered by a sight Area2D. Build the coin as an Area2D that emits collected and frees itself. Build a HUD scene with a score label that increases only through the signal. Verify the decoupling: delete the HUD from the level and confirm the coin still works with no errors, then put it back.

Keep a table with three columns: what I expected, what actually happened, what the cause was. Every bug you hit goes in, including the ones you fix in ten seconds. Then duplicate your enemy until the level has at least 200, open the Profiler and Monitors, and record FPS and physics time. Try one optimization from 4.5, measure again, and state in one sentence whether it helped and how you know. Submit the log, the two measurements, and your slice.


  • I can explain why a reusable scene is like a class and an instance like an object
  • I can describe composition over inheritance and give a Godot example
  • I can draw a state machine for a game object and implement it with enum and match
  • I can declare typed, exported and onready variables in GDScript
  • I can explain when to use _process versus _physics_process
  • I can declare, emit and connect a custom signal
  • I can choose between StaticBody2D, RigidBody2D, CharacterBody2D and Area2D
  • I can set collision layers and masks so the right objects interact
  • I can find a bug with breakpoints and justify an optimization with the profiler