Module 2: GDScript for Programmers
13 min read
#What We Have So Far
A Godot project called Crystal Saga with a single scene containing a Sprite2D, configured for pixel-perfect rendering.
#What We're Building This Module
A script attached to our sprite that responds to keyboard input and moves around the screen. Along the way, we'll learn GDScript's syntax, Godot's virtual function system, and how input works.
#GDScript at a Glance
GDScript is Godot's built-in scripting language. It looks like Python but is a completely separate language, built for game development. If you've written Python, JavaScript, or any dynamically typed language, you'll feel right at home. If you're coming from C#, Java, or C++, the indentation-based syntax might take a few minutes to adjust to, but the concepts are all familiar.
Here's a quick taste:
extends Sprite2D
var speed: float = 200.0
var health: int = 100
func _ready() -> void:
print("Hello from Crystal Saga!")
func _process(delta: float) -> void:
if health <= 0:
print("Game over!")
Key things to notice:
- Indentation defines code blocks (like Python), not braces.
extendsdeclares what class this script inherits from.- Static typing is optional but recommended. We use it everywhere in this tutorial.
var speed: float = 200.0is statically typed.var speed = 200.0is dynamically typed and also valid. - Functions starting with
_are special: they're callbacks that Godot calls for you.
See: GDScript reference, the complete language reference covering syntax, types, and features.
#Creating and Attaching a Script
We'll add behavior to our Sprite2D from Module 1.
- Open your
main.tscnscene. - Select the
Sprite2Dnode in the scene tree. - Click the Attach Script button (the scroll icon with a green
+at the top of the scene dock), or right-click the node and choose Attach Script. - In the dialog that appears:
- Language: GDScript
- Path:
res://sprite_2d.gd(default is fine for now) - Template: Default
- Click Create.
The editor switches to the Script workspace, showing your new script:
extends Sprite2D
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
pass # Replace with function body.
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
pass
This template gives us the two most important virtual functions. Here's what they do.
#Virtual Functions: _ready() and _process()
Godot doesn't ask you to write a game loop or manage update timing. Instead, it calls specific functions on your scripts at specific moments. These are called virtual functions (or callbacks).
#_ready()
Called once, when the node and all its children have entered the scene tree. This is where you initialize things: set starting values, cache references to other nodes, connect signals.
func _ready() -> void:
print("I'm alive! My position is: ", position)
Try it now: replace the pass in _ready() with that print line, save the script, and press F5. You should see the message appear in the Output panel at the bottom of the editor. Stop the game (F8) and continue.
#_process(delta)
Called every frame, typically 60 times per second (or more, depending on the display). The delta parameter is the time in seconds since the last frame. This is where you handle movement, input, animation updates, and anything that needs to happen continuously.
Why delta? Because frame rates vary. If your game runs at 60 FPS on one machine and 30 FPS on another, moving 5 pixels per frame would make the player move twice as fast on the faster machine. By multiplying movement by delta, you make motion frame-rate independent:
func _process(delta: float) -> void:
# Without delta: moves 5 pixels per frame (speed depends on FPS)
# position.x += 5
# With delta: moves 200 pixels per second (consistent regardless of FPS)
position.x += 200.0 * delta
See: Idle and physics processing, which explains
_process(),_physics_process(), and when to use each.
There's also _physics_process(delta), which is called at a fixed rate (60 times per second by default, regardless of frame rate). We'll use this in Module 3 when we add physics-based movement. For now, _process() is all we need.
See: Overridable functions, the full list of virtual functions Godot provides, including
_enter_tree(),_exit_tree(), and_input().
#Variables and Types
Static typing matters more in game code than in most software. Games run in real-time; a type error in a web app shows you an error page, but a type error in a game crashes mid-boss-fight and the player loses their progress. Imagine you're building Final Fantasy VI's magic system and you accidentally pass a string where an integer is expected for spell damage. Without static typing, the bug hides until a playtester casts Fire 3 on the final boss and the game crashes. With static typing, the editor underlines the mistake the moment you type it.
GDScript supports both dynamic and static typing. We always use static typing in this tutorial because it catches bugs at write-time rather than runtime, and gives us better autocompletion in the editor.
#Declaring Variables
# Explicit type annotation (our preferred style)
var speed: float = 200.0
var player_name: String = "Aiden"
var health: int = 100
var is_alive: bool = true
# Type inference with := (the type is inferred from the value)
var speed := 200.0 # float
var player_name := "Aiden" # String
# No type (dynamic typing, works but we avoid it)
var speed = 200.0 # could be anything
#Common Types
| Type | Example | Notes |
|---|---|---|
int | 42 | Whole numbers |
float | 3.14 | Decimal numbers |
bool | true, false | |
String | "hello" | Use double quotes |
Vector2 | Vector2(10, 20) | 2D position/direction (you'll use this constantly) |
Array | [1, 2, 3] | Ordered list |
Dictionary | {"hp": 100} | Key-value pairs |
#Constants
const MAX_SPEED: float = 300.0
const PLAYER_NAME: String = "Aiden"
Constants use UPPER_SNAKE_CASE and cannot be changed after declaration.
#Functions
# A function with parameters and a return type
func calculate_damage(attack: int, defense: int) -> int:
return max(1, attack - defense)
# A function that returns nothing (void)
func take_damage(amount: int) -> void:
health -= amount
if health <= 0:
die()
# A function with a default parameter
func heal(amount: int = 10) -> void:
health = min(health + amount, max_health)
Notice the return type annotation (-> int, -> void). This is part of static typing and helps both you and the editor understand what the function produces.
#Control Flow
GDScript's control flow is straightforward if you've programmed before:
# If / elif / else
if health <= 0:
print("Dead")
elif health < 20:
print("Critical!")
else:
print("Fine")
# For loops
for i in range(5): # 0, 1, 2, 3, 4
print(i)
for item in inventory: # iterate over an array
print(item.name)
# While loops
while health > 0:
health -= 1
# Match (like switch/case)
match direction:
"up":
velocity.y = -speed
"down":
velocity.y = speed
_:
velocity = Vector2.ZERO # default case
The match statement routes behavior when a value can be one of several named options, and in game development, this comes up constantly. Think about Pokemon's type effectiveness: when a Water-type move hits, the game checks whether the target is Fire, Grass, Rock, or Electric and responds differently for each. A chain of if/elif works but gets unwieldy fast. match makes the options explicit and readable. It becomes essential when we build state machines in Module 6.
Note: GDScript uses
and,or, andnotinstead of&&,||, and!. Both work, butand/or/notare the idiomatic choice and what we'll use throughout.
#The @export and @onready Annotations
These two annotations are GDScript-specific and very handy.
#@export: Edit in the Inspector
Think about Dragon Quest's towns. Every NPC shares the same basic behavior (stand in place, say a line of dialogue when you talk to them) but each one says something different. Without @export, you would need a separate script for every NPC: guard_npc.gd, baker_npc.gd, child_npc.gd, dozens of nearly identical files that differ only in their dialogue text. With @export, you write one npc.gd script and set each NPC's dialogue, name, and portrait directly in the editor. One script, dozens of unique characters.
@export exposes a variable in the Inspector panel, so you can tweak it per-instance without editing code:
@export var speed: float = 200.0
@export var player_name: String = "Aiden"
After adding these, select the node in the editor and look at the Inspector. You'll see Speed and Player Name as editable fields. This is how we'll customize NPCs, items, and other game objects without writing separate scripts for each one.
#@onready: Cache Node References
In a JRPG battle scene like Chrono Trigger's, you might update the HP bar, the character sprite, and the status icons every single frame, potentially hundreds of node lookups per second. Each get_node() call walks the scene tree by name, which is slow compared to using a cached reference. @onready grabs the reference once when the node loads and stores it in a variable, so every subsequent access is instant. It also guarantees the reference is valid; without it, you might try to grab a child node before it exists and get a null crash at startup.
@onready initializes a variable when _ready() is called, which is when the node tree is guaranteed to be built:
@onready var sprite: Sprite2D = $Sprite2D
@onready var health_bar: ProgressBar = $UI/HealthBar
The $ operator is shorthand for get_node(). $Sprite2D means "find the child node named Sprite2D." $UI/HealthBar means "find the child named UI, then find its child named HealthBar."
Warning: If you try to access
$Sprite2Din a plainvardeclaration (outside@onready), it will fail because the node tree hasn't been built yet whenvarinitializers run. Always use@onreadyfor node references.
There's also %UniqueName, which finds a node by its unique name regardless of where it is in the tree. We'll use this later for UI elements that need to be found from anywhere.
#Input: Making Things Move
Now for the fun part: making our sprite respond to keyboard input.
#The Input Map
When Undertale launched, players immediately wanted to rebind controls. Some preferred WASD, others used gamepads, and accessibility needs varied. If Toby Fox had hard-coded "check if the Z key is pressed" throughout the codebase, adding gamepad support would have meant hunting down every key check and adding a parallel gamepad check beside it. Input actions solve this by putting a name between your code and the physical keys.
Godot doesn't check for raw key codes directly (though it can). Instead, it uses actions, named inputs that can be mapped to multiple keys, buttons, or axes. This means your game automatically works with both keyboard and gamepad without extra code.
The default project includes several built-in actions:
| Action | Default Keys |
|---|---|
ui_up | Arrow Up |
ui_down | Arrow Down |
ui_left | Arrow Left |
ui_right | Arrow Right |
ui_accept | Enter, Space |
ui_cancel | Escape |
You can view and edit these in Project → Project Settings → Input Map.
Note: The
ui_*actions only map to arrow keys by default; WASD is not included. To add WASD support: open Project → Project Settings → Input Map, findui_up, click the + button next to it, press W, and click OK. Repeat forui_down(S),ui_left(A), andui_right(D). Now both arrow keys and WASD will work.
Note:
Inputis a globally available singleton, what Godot calls an "autoload." You access it by name from anywhere:Input.is_action_pressed("ui_up"). We'll create our own autoloads in Module 7. For now, just know that some objects (likeInput,Engine,Time) are always available because Godot provides them globally.
#Checking Input
There are several ways to check input. The most common:
# Is the action currently held down? (returns true every frame it's held)
Input.is_action_pressed("ui_up")
# Was the action just pressed this frame? (returns true for one frame only)
Input.is_action_just_pressed("ui_accept")
# Was the action just released this frame?
Input.is_action_just_released("ui_accept")
# Get a value between -1 and 1 for an axis (useful for analog sticks)
Input.get_axis("ui_left", "ui_right")
See: Input examples, the official tutorial covering input handling patterns.
#Moving the Sprite
Replace the contents of sprite_2d.gd with:
extends Sprite2D
@export var speed: float = 200.0
func _process(delta: float) -> void:
var direction := Vector2.ZERO
if Input.is_action_pressed("ui_right"):
direction.x += 1.0
if Input.is_action_pressed("ui_left"):
direction.x -= 1.0
if Input.is_action_pressed("ui_down"):
direction.y += 1.0
if Input.is_action_pressed("ui_up"):
direction.y -= 1.0
# Normalize to prevent faster diagonal movement
if direction != Vector2.ZERO:
direction = direction.normalized()
position += direction * speed * delta
Here's what each part does:
var direction := Vector2.ZERO: We start with no movement.Vector2.ZEROisVector2(0, 0).- Input checks: We check each direction independently. Using
if(notelif) allows diagonal movement. direction.normalized(): Without this, moving diagonally would be ~41% faster than moving horizontally or vertically (because the diagonal of a unit square is √2 ≈ 1.414). Normalizing makes the vector length exactly 1 in all directions.position += direction * speed * delta: Move the sprite byspeedpixels per second in the input direction, scaled bydeltafor frame-rate independence.
Save the script and press F5. Use the arrow keys to move the sprite around (or WASD if you added those bindings above). The Godot icon slides smoothly across the screen.
JRPG Pattern: This is "free movement," where the character moves smoothly in any direction. Some JRPGs use "grid-based movement" instead, where the character snaps from tile to tile. We'll discuss this tradeoff in Module 6 and implement free movement for Crystal Saga.
#print() Debugging
The simplest debugging tool is print(). It outputs to the Output panel at the bottom of the editor.
func _ready() -> void:
print("Game started!")
print("Speed is: ", speed)
print("Position: ", position)
func _process(delta: float) -> void:
# This will flood the output, so use sparingly!
# print("Frame! Delta: ", delta)
pass
Other useful output functions:
print("Normal message") # White text in Output
push_warning("Something seems off") # Yellow warning
push_error("Something broke!") # Red error (doesn't crash)
printerr("Also an error") # Error output
Warning: Don't leave
print()calls inside_process()in production code. Printing 60+ messages per second will slow your game down. Use them for debugging, then remove them.
#The Script Editor
A few tips for working in Godot's built-in script editor:
- Ctrl+Click on a function or variable name to jump to its definition.
- F1 opens the built-in documentation search. Type any class name to see its full API.
- Ctrl+Shift+D duplicates the current line.
- Ctrl+/ toggles comment on the selected lines.
- Ctrl+Space triggers autocompletion.
The built-in docs (F1) are very thorough. When you look up Sprite2D, you'll see every property, method, and signal the class has. We'll refer to these frequently.
#Common Gotchas
If you're coming from another language, watch for these:
#From Python
- GDScript is not Python. Many things look similar but work differently under the hood.
- GDScript has a
selfkeyword, but you rarely need it. Member variables are accessed directly without it (health, notself.health).selfexists for disambiguation when a local variable shadows a member variable. - Dictionaries use
{"key": value}syntax (with colons), not{key: value}without quotes (though GDScript also supports the{key = value}form).
#From JavaScript/TypeScript
- Indentation matters. Inconsistent tabs/spaces will cause errors.
- No semicolons. No braces for blocks.
nullis callednull(same as JS), but types often use specific "empty" values likeVector2.ZEROor"".
#From C#/Java
- No class declarations; each
.gdfile is implicitly a class. extendsinstead of: BaseClassorextends BaseClass.- No access modifiers (
public,private). By convention, prefix private members with_(e.g.,_internal_var). - Arrays and dictionaries are dynamic, with no generics syntax (though typed arrays exist:
Array[int]).
#Universal
- Tabs for indentation, not spaces. Godot enforces this by default.
- Double quotes for strings (single quotes work but double is convention).
- Two blank lines between functions. One blank line between logical sections within a function.
See: GDScript style guide, the official style conventions for GDScript code.
#Putting It Together: A Complete First Script
Replace our movement code with a cleaner version. Copy this complete script into sprite_2d.gd, replacing everything that was there:
extends Sprite2D
## A simple movable sprite, our first step toward a player character.
@export var speed: float = 200.0
func _ready() -> void:
print("Crystal Saga: sprite loaded at ", position)
func _process(delta: float) -> void:
var direction := _get_input_direction()
if direction != Vector2.ZERO:
direction = direction.normalized()
position += direction * speed * delta
func _get_input_direction() -> Vector2:
return Vector2(
Input.get_axis("ui_left", "ui_right"),
Input.get_axis("ui_up", "ui_down"),
)
Notice two improvements:
Input.get_axis(): This is a cleaner way to get directional input. It returns a float between -1 and 1, handling both keyboard and analog sticks automatically. We replaced fourifblocks with a singleVector2.Extracted
_get_input_direction(): The input logic is now in its own function. The_prefix signals that it's a private helper. This is a habit worth building early; small functions with clear names make your code much easier to read and modify.
See: InputEvent, a deep dive into Godot's input event system, including how events propagate through the scene tree.
#What We've Learned
- GDScript is Godot's scripting language, with Python-like syntax designed for game development.
- Static typing (
var x: int = 5) catches bugs early and enables better autocompletion. _ready()runs once when the node enters the scene._process(delta)runs every frame.deltamakes movement frame-rate independent. Always multiply speed bydelta.- Input actions abstract away raw keys.
Input.is_action_pressed("ui_right")works with keyboard and gamepad. Input.get_axis()is a clean way to get -1/0/1 directional input.@exportexposes variables in the Inspector.@onreadycaches node references safely.$NodeNamefinds child nodes by path.print()is your basic debugging tool.
#What You Should See
When you press F5:
- The sprite responds to arrow keys (and WASD if you added those bindings)
- Movement is smooth and consistent
- Diagonal movement is the same speed as cardinal movement (thanks to
normalized()) - The Output panel shows the startup print message
#Next Module
We have a moving sprite, but it's just a floating icon. In Module 3: Thinking in Scenes, we'll build a proper Player scene with collision physics, learn when to use CharacterBody2D vs other body types, and understand scene composition, the architectural principle that makes complex games manageable.