Building Your First Interactive Character
Build a character that moves with arrow keys
Along the way, learn the fundamental building blocks of every Godot game
The foundation of everything in Godot
Godot's architecture: everything is a Node
A player, a background, a sound effect, a timer — all nodes
Each node type has a specific job:
| Node Type | What It Does |
|---|---|
Node |
Base type. Can hold logic and children. |
Node2D |
Adds 2D position, rotation, scale. |
Sprite2D |
Displays an image (texture) on screen. |
Area2D |
Detects overlaps with other objects. |
AudioStreamPlayer |
Plays sound. |
There are hundreds of node types. You'll learn them gradually.
A scene is a tree of nodes saved as a .tscn file
A scene can be:
Key insight: Scenes are reusable — drag them into other scenes!
Our entire game is one scene:
Game (Node2D) ← root of the scene
└── Player (Node2D) ← has a position we can move
└── Sprite2D ← displays the icon image
Why nest Sprite2D under Player?
Because Player is a container. Later we'll add collision shapes, health bars, particles under it.
When you press Play, Godot builds a scene tree
A live hierarchy of every node currently in the game
The engine walks this tree every frame, calling functions on each node
This is the heartbeat of your game
May surprise you if you're coming from math class!
(0, 0) ——————————→ +X (right)
|
|
|
↓
+Y (down)
Historical reason: screens draw pixels top-to-bottom, left-to-right
This is standard in almost all 2D game engines
| Position | Where on Screen |
|---|---|
(0, 0) |
Top-left corner |
(1152, 0) |
Top-right corner |
(0, 648) |
Bottom-left corner |
(576, 324) |
Dead center |
To move up on screen → subtract from Y
To move down → add to Y
This trips up everyone at first. You'll get used to it!
A Vector2 is a pair of numbers (x, y)
Can represent: position, direction, velocity, or anything with two components
Addition: (2, 3) + (1, -1) = (3, 2) → combining movement
Scalar multiply: (1, 0) * 5 = (5, 0) → scaling (speed)
Length: |(3, 4)| = 5 → distance from origin
Normalized: (3, 4).normalized() = (0.6, 0.8) → same direction, length 1
Suppose the player presses right + down simultaneously:
direction = (1, 0) + (0, 1) = (1, 1)
length of (1, 1) = √2 ≈ 1.414
The player moves ~41% faster diagonally! That's unfair.
(1, 1).normalized() = (0.707, 0.707)
length = 1.0 ✓
Now all directions produce the same speed!
If you know Python, you're 80% there
for, if, while work the sameprint() works the same| Python | GDScript | Notes |
|---|---|---|
class MyClass: |
extends Node2D |
Every script extends a node type |
self.x |
x or position |
No self keyword |
def __init__(self): |
func _ready(): |
Called when node enters scene |
def update(self, dt): |
func _process(delta): |
Called every frame |
x: int = 5 |
var x: int = 5 |
Variables declared with var |
def foo(self): |
func foo(): |
Functions declared with func |
self?In Python, every method has explicit self
In GDScript, the node is the implicit context
# GDScript — these are equivalent:
position.x += 5
self.position.x += 5 # works, but nobody writes this
When you write position, it means "this node's position"
Understanding the heartbeat of your game
When you attach a script to a node, you give it custom behavior
extends Node2D # "I am a Node2D with extra behavior"
position, rotation, etc.)Every game engine runs an infinite loop:
while game_is_running:
process_input()
update_all_objects() ← your _process() runs here
render_frame()
wait_for_vsync() ← targets ~60 FPS
Each iteration = one frame (~16.6 milliseconds at 60 FPS)
During "update all objects", Godot walks the entire scene tree
Frame 1: Game._process() → Player._process() → (Sprite2D has no script)
Frame 2: Game._process() → Player._process() → ...
Frame 3: Game._process() → Player._process() → ...
...60 times per second
If _process exists on a node, it runs. Every. Single. Frame.
| Function | When It Runs | Use It For |
|---|---|---|
_ready() |
Once when node enters scene | Initialization, set starting values |
_process(delta) |
Every frame (~60 times/sec) | Game logic, movement, animation |
_input(event) |
Every input event | Responding to discrete events |
There are others (_physics_process, _enter_tree) — we'll meet them later
position?Every Node2D has a built-in property: position
A Vector2 representing where the node sits relative to its parent
position.x += 5 # move 5 pixels to the right
Since our Player's parent is Game (at origin), Player.position is effectively the screen position
deltaThe secret to smooth, consistent gameplay
func _process(delta):
position.x += 5 # move 5 pixels per frame
At 60 FPS → 300 pixels/sec
At 30 FPS → 150 pixels/sec
Game speed depends on framerate. That's bad!
deltadelta = time in seconds since the last frame
At 60 FPS, delta ≈ 0.0166
At 30 FPS, delta ≈ 0.0333
func _process(delta):
position.x += 300 * delta # 300 pixels per SECOND
At 60 FPS: 300 × 0.0166 = 5.0 pixels/frame → 300 px/sec ✓
At 30 FPS: 300 × 0.0333 = 10.0 pixels/frame → 300 px/sec ✓
Rule: Always multiply movement by delta
Print a message after 3 seconds:
extends Node2D
var timer = 0.0
var message_printed = false
func _process(delta):
timer += delta
if timer >= 3.0 and not message_printed:
print("3 seconds have passed!")
message_printed = true
Key takeaway: delta turns discrete frames into smooth, continuous time
Without blocking!
Console program approach:
key = input("Press a key: ") # BLOCKS — program freezes
Games can never do this!
If the game froze waiting for input, nothing would move, animate, or render
Godot checks the state of all keys every frame
if Input.is_action_pressed("ui_right"):
# Right arrow is currently held down — this frame
This is called polling
It doesn't wait. It doesn't block. It just checks: "Is this key down right now?"
| Function | Returns true when... |
|---|---|
is_action_pressed() |
Key is held down (true every frame) |
is_action_just_pressed() |
Key was just pressed (true for one frame) |
For movement, we want is_action_pressed — keep moving while held
Godot maps physical keys to named actions
| Action Name | Default Key |
|---|---|
"ui_right" | → (Right Arrow) |
"ui_left" | ← (Left Arrow) |
"ui_up" | ↑ (Up Arrow) |
"ui_down" | ↓ (Down Arrow) |
"ui_accept" | Enter / Space |
You can define custom actions in Project → Project Settings → Input Map
Step-by-step live demo
WandererNode2D → CreateGameGame → + button → Node2D → rename to PlayerPlayer → Add Child Node → Sprite2DSprite2D → Inspector → Texture → drag icon.svgPlayer → Inspector → Position → (576, 324)game.tscnGame (Node2D)
└── Player (Node2D)
└── Sprite2D
You should see the Godot icon centered in the viewport
Player nodeextends Node2D
var speed = 300.0
func _process(delta):
var direction = Vector2.ZERO # (0, 0) — no movement yet
if Input.is_action_pressed("ui_right"):
direction.x += 1
if Input.is_action_pressed("ui_left"):
direction.x -= 1
if Input.is_action_pressed("ui_down"):
direction.y += 1 # remember: +Y is DOWN
if Input.is_action_pressed("ui_up"):
direction.y -= 1 # -Y is UP
if direction.length() > 0:
direction = direction.normalized() # fix diagonal speed
position += direction * speed * delta
game.tscn🎉 You just made your first interactive game object!
What happens every frame (~60 times/sec):
direction starts as (0, 0)if check adds to the direction vector(1, 0), Right + Up → (1, -1)direction * speed * delta = movement vectorposition += moves the Player nodePattern: Build direction → normalize → apply speed & delta
Keeping the player on screen
Hold Right → character disappears off the edge!
We need to clamp (restrict) the position to stay within the screen
var screen_size = get_viewport_rect().size
# Returns a Vector2, e.g. (1152, 648)
get_viewport_rect() returns the rectangle of the current viewport
.size gives width and height as a Vector2
clamp() Functionclamp(value, min, max) forces a number to stay within a range:
clamp(500, 0, 1152) → 500 (already in range)
clamp(-20, 0, 1152) → 0 (was below min)
clamp(1200, 0, 1152) → 1152 (was above max)
Add these lines at the end of _process:
var screen_size = get_viewport_rect().size
position.x = clamp(position.x, 0, screen_size.x)
position.y = clamp(position.y, 0, screen_size.y)
Run it again — the icon now stops at the edges!
extends Node2D
var speed = 300.0
func _process(delta):
var direction = Vector2.ZERO
if Input.is_action_pressed("ui_right"):
direction.x += 1
if Input.is_action_pressed("ui_left"):
direction.x -= 1
if Input.is_action_pressed("ui_down"):
direction.y += 1
if Input.is_action_pressed("ui_up"):
direction.y -= 1
if direction.length() > 0:
direction = direction.normalized()
position += direction * speed * delta
# Keep player inside the screen
var screen_size = get_viewport_rect().size
position.x = clamp(position.x, 0, screen_size.x)
position.y = clamp(position.y, 0, screen_size.y)
| Nodes | Building blocks of everything in Godot |
| Scenes | Saved tree of nodes (`.tscn`). Reusable. |
| Scene Tree | Live hierarchy at runtime. Engine walks it every frame. |
| Coordinate System | Origin = top-left. +X = right. +Y = down. |
| extends | GDScript's version of class inheritance |
| No self | Access node properties directly (position, rotation) |
| _ready() | Runs once when node enters the tree |
| _process(delta) | Runs every frame. The heartbeat of game logic. |
| delta | Seconds since last frame. Always multiply by it! |
| position | Vector2 — where this node is relative to parent |
| Input polling | is_action_pressed() — non-blocking, checked every frame |
| normalized() | Scales vector to length 1. Prevents faster diagonals. |
| clamp() | Restricts value to min/max range |
Try these on your own!
speed to 600, then 100. What feels "right"?delta each frame. Print "Hello!" every 2 seconds (reset timer after each print).if statements instead of clamp.Input.is_key_pressed(KEY_SHIFT).get_viewport().get_mouse_position(), calculate direction, normalize.direction.angle()rotation = lerp_angle(rotation, target_angle, 5.0 * delta)if direction.length() > 0We'll add:
Area2D)Building on today's foundation to make a real game!
Try building Wanderer on your own
Experiment with the exercises
Office hours: [Your time here]