Learn GDScript by trying variables, conditions, loops, and functions in a small Godot scene. The examples explain what the code does and how to recognize common errors. Keep the editor open so you can change a value and see the result as you read.
Part 1: The Absolute Basics (5 minutes)
Variables: Storing Stuff
A variable is a named value. var creates one; the name uses lowercase words joined by underscores, which is the convention the whole engine follows.
# Basic variables
var health = 100
var player_name = "Hero"
var speed = 5.5
var is_alive = true
# With type hints (recommended)
var health: int = 100
var player_name: String = "Hero"
var speed: float = 5.5
var is_alive: bool = trueThe colon form, var health: int = 100, tells Godot the variable will only ever hold an integer. The benefit shows up immediately: the editor autocompletes methods on typed variables, and mistakes fail at parse time, before the game runs, with messages like Cannot assign a value of type String to variable "health" with specified type int. When the type is obvious from the value you can let Godot infer it with :=, as in var speed := 5.5. You cannot declare the same variable twice in one script, so in your own file keep only one of the two blocks above.
Constants: Values That Never Change
# Constants use UPPER_CASE by convention
const MAX_HEALTH = 100
const GRAVITY = 980.0
const GAME_TITLE = "My Awesome Game"A constant is a value the script promises not to change. Assigning to one later fails at parse time with Cannot assign a new value to a constant. Use constants for tuning values that are the same for every instance; for values you want to tweak per enemy or per level in the editor, use @export from Part 5 instead.
Basic Math
var damage = 25
var defense = 10
var actual_damage = damage - defense # 15
var base_speed = 100
var boost = 1.5
var boosted_speed = base_speed * boost # 150.0
# Useful shortcuts
health -= 20 # Same as: health = health - 20
score += 100 # Same as: score = score + 100Integer and float arithmetic follow one rule worth memorising: an operation between two integers gives an integer, so 7 / 2 is 3, not 3.5. Write 7.0 / 2 or float(7) / 2 when you want the fraction. The editor warns about the integer division case; do not ignore that warning, because it is the source of a lot of "my movement is jerky" bugs.
Part 2: Making Decisions (5 minutes)
If Statements
if health <= 0:
print("Game Over!")
elif health < 30:
print("Low health warning!")
else:
print("Health is fine")
# You can combine conditions
if is_alive and health > 0:
print("Player is active")
if has_key or is_admin:
print("Door opens")The colon ends the condition and the indented block underneath is what runs. GDScript checks branches top to bottom and runs the first one that is true, so order the conditions from most specific to most general: if the health < 30 check came first, a dead player would get a "low health warning" instead of a game over.
Comparison Operators
# These all return true or false
health == 100 # Equals
health != 0 # Not equals
health > 50 # Greater than
health < 30 # Less than
health >= 100 # Greater or equal
health <= 0 # Less or equalTwo equals signs compare; one assigns. Writing if health = 0: is a parse error in GDScript rather than a silent bug.
Part 3: Loops (5 minutes)
For Loops: Do Something X Times
# Count from 0 to 4
for i in range(5):
print(i) # Prints: 0, 1, 2, 3, 4
# Loop through a list
var fruits = ["apple", "banana", "orange"]
for fruit in fruits:
print(fruit)
# Spawn 10 enemies
for i in range(10):
spawn_enemy()range(5) produces 0 to 4, never 5. Looping over an array gives you each element directly, with no index bookkeeping. One rule to remember: do not remove items from an array while looping over it, or elements get skipped; collect what you want to remove in a second array and remove afterwards.
While Loops: Do Something Until...
var countdown = 10
while countdown > 0:
print(countdown)
countdown -= 1
print("Blast off!")A while loop repeats as long as its condition is true, so something inside the loop must eventually make it false. Forget the countdown -= 1 and the game freezes, because the loop never returns control to the engine. In game code you rarely need while; things that should happen "every frame until" belong in _process, which Part 5 introduces.
Part 4: Functions (5 minutes)
Functions are reusable chunks of code with a name. They are how you organise your game logic, and how Godot talks to your script: every lifecycle callback and signal handler is a function.
# Basic function
func say_hello():
print("Hello!")
# Function with parameters
func greet(name):
print("Hello, " + name + "!")
# Function that returns a value
func add(a, b):
return a + b
# Function with typed parameters (recommended)
func take_damage(amount: int) -> void:
health -= amount
if health <= 0:
die()
# Using functions
say_hello() # Prints: Hello!
greet("Player") # Prints: Hello, Player!
var sum = add(5, 3) # sum is now 8-> void after the parameter list says the function returns nothing; -> int would promise an integer. The typed form catches the most common beginner mistake in greet(): calling greet(42) tries to add a String and an int and fails at runtime with Invalid operands 'String' and 'int' in operator '+'. Convert with str(name), or better, build strings with a format placeholder: "Hello, %s!" % name works with any type.
One more rule: the calls at the bottom of that block only work inside another function, such as _ready(). Code at the top level of a script can declare variables and functions, but statements like say_hello() there give Unexpected identifier in class body.
Part 4½: Arrays and Dictionaries (5 minutes)
An Array is an ordered list; a Dictionary maps keys to values. Both can be typed.
# Arrays: ordered, zero-indexed
var inventory: Array[String] = ["sword", "potion"]
inventory.append("key") # ["sword", "potion", "key"]
print(inventory[0]) # sword
print(inventory.size()) # 3
if "potion" in inventory:
inventory.erase("potion")
# Dictionaries: key -> value
var stats: Dictionary = {"health": 100, "speed": 200.0}
stats["mana"] = 50 # add a key
print(stats["health"]) # 100
print(stats.get("armor", 0)) # 0 (default, no crash)
for key in stats:
print(key, " = ", stats[key])Array[String] is a typed array: appending a number to it fails immediately instead of exploding later inside some other function. Reading a dictionary key that does not exist with square brackets throws Invalid get index 'armor' (on base: 'Dictionary'), which can happen when a save file is missing a field; get(key, default) avoids it. Arrays and dictionaries are passed by reference, so a function that receives your inventory and calls append() changes the original.
Part 5: Godot-Specific Stuff (10 minutes)
The following examples attach scripts to nodes and use the engine’s lifecycle callbacks. GDScript can also define Resource classes and other objects that are not nodes in the scene tree.
Every Script Starts Like This
extends CharacterBody2D # What type of node this script is for
# Variables go here
var health: int = 100
var speed: float = 200.0
# Called when the node enters the scene
func _ready():
print("I'm alive!")
# Called every frame (~60 times per second)
func _process(delta):
# delta is time since last frame
# Use it for smooth movement
pass
# Called at fixed intervals (for physics)
func _physics_process(delta):
# Use this for movement and collisions
passextends must match the node the script is attached to, or be one of its parents; attaching a script that extends CharacterBody2D to a plain Node2D fails with Script inherits from native type 'CharacterBody2D', so it can't be assigned to an object of type 'Node2D'. _ready() runs once, after the node and all its children exist, which is why node lookups belong there and not in a variable initialiser. _process(delta) runs every rendered frame and _physics_process(delta) runs at a fixed rate (60 times per second by default); movement and collision code goes in the physics one so it behaves the same on a 30 Hz laptop and a 144 Hz monitor.
delta is the number of seconds since the previous call. Multiply anything per-second by it: position.x += speed * delta moves speed pixels per second regardless of frame rate, while position.x += speed moves faster on faster machines.
Getting Player Input
Use input actions to keep gameplay code independent of physical keys. You define named actions under Project Settings > Input Map (for example move_left bound to A and the left arrow), and the script asks about the action. Skip that step and every call below logs The InputMap action "move_left" doesn't exist and the intended input will not be detected.
func _process(delta):
# Check if a key is held down
if Input.is_action_pressed("move_right"):
position.x += speed * delta
# Check if a key was just pressed (once)
if Input.is_action_just_pressed("jump"):
jump()
# Get axis input (-1, 0, or 1)
var direction = Input.get_axis("move_left", "move_right")
position.x += direction * speed * deltais_action_pressed is true every frame the key is held; is_action_just_pressed is true for one frame only, which is what jumping and shooting want. get_axis combines two actions into a single number, and it also handles analog sticks, returning values between -1 and 1 instead of just the extremes.
@export: Tweak Values in the Editor
# These appear in the Godot Inspector!
@export var speed: float = 200.0
@export var jump_force: float = 400.0
@export var max_health: int = 100
# You can add categories
@export_category("Movement")
@export var acceleration: float = 50.0
@export var friction: float = 30.0
@export_category("Combat")
@export var damage: int = 10
@export var attack_cooldown: float = 0.5@export puts the variable in the Inspector, and the value set there is saved with the scene, per instance. That is how one enemy scene becomes a fast enemy and a slow enemy without two scripts. The type matters here more than anywhere: the Inspector shows a number field for float, a checkbox for bool, a resource picker for PackedScene. A declaration such as @export var speed needs a type or an initializer from which Godot can infer one; it does not create a generic Inspector field.
@onready: Get Node References
# Get references to child nodes
@onready var sprite = $Sprite2D
@onready var animation_player = $AnimationPlayer
@onready var collision = $CollisionShape2D
# Use them in your code
func die():
animation_player.play("death")
collision.disabled = true$Sprite2D is shorthand for get_node("Sprite2D"), looked up relative to the node the script is on. Plain var sprite = $Sprite2D at the top of a script runs before the children exist and stores null; the @onready annotation delays the assignment until just before _ready(). If a name is wrong, Godot logs Node not found: "Sprite2d" with the exact path it tried, and names are case-sensitive. Typing the variable, @onready var sprite: Sprite2D = $Sprite2D, gives autocompletion on it.
Signals: Events Between Nodes
# Define your own signal
signal health_changed(new_health)
signal died
# Emit signals when things happen
func take_damage(amount: int):
health -= amount
health_changed.emit(health) # Tell everyone health changed
if health <= 0:
died.emit() # Tell everyone we died
# Connect to signals (in another script)
func _ready():
player.health_changed.connect(_on_health_changed)
player.died.connect(_on_player_died)
func _on_health_changed(new_health):
health_bar.value = new_health
func _on_player_died():
show_game_over_screen()A signal is an announcement. The player script says "my health changed" without knowing who cares; the HUD connects to that signal and updates its bar. This is how Godot keeps scripts independent: the player never references the HUD, so you can delete the HUD and the player still works. Godot's own nodes are covered in built-in signals (Button.pressed, Area2D.body_entered, Timer.timeout), and you connect to them the same way. The signals tutorial goes through when to connect in code versus in the editor and how to debug a signal that never fires.
Part 6: Common Patterns You'll Use Daily
Basic 2D Player Movement
extends CharacterBody2D
@export var speed: float = 200.0
@export var jump_force: float = -400.0
@export var gravity: float = 980.0
func _physics_process(delta):
# Apply gravity
if not is_on_floor():
velocity.y += gravity * delta
# Jump
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_force
# Horizontal movement
var direction = Input.get_axis("move_left", "move_right")
velocity.x = direction * speed
# Apply movement
move_and_slide()This is the whole platformer controller. velocity is a built-in property of CharacterBody2D; you set it, and move_and_slide() moves the body by it, stopping at walls and floors and updating is_on_floor() for next frame. Jump force is negative because in 2D the y axis points down. If the character falls through the floor, the floor has no CollisionShape2D or is on a physics layer the player's mask does not include; the collision layers article explains that setup.
Spawning Objects
# Load the scene you want to spawn
@export var bullet_scene: PackedScene
func shoot():
# Create an instance
var bullet = bullet_scene.instantiate()
# Set its position
bullet.position = position
bullet.rotation = rotation
# Add it to the game
get_parent().add_child(bullet)A saved scene is a PackedScene resource; instantiate() builds a fresh copy of its node tree, and add_child() puts it in the running game. Nothing appears until it is added. The bullet is added to the parent rather than to the player so that it keeps flying when the player turns; a child would rotate with its parent. Drag the bullet scene from the FileSystem dock into the exported field in the Inspector.
Timer / Cooldown
var can_shoot: bool = true
func _process(delta):
if Input.is_action_just_pressed("shoot") and can_shoot:
shoot()
can_shoot = false
# Wait 0.5 seconds, then allow shooting again
await get_tree().create_timer(0.5).timeout
can_shoot = trueawait pauses this function until the signal fires and lets the rest of the game keep running; it is not a sleep. The line after await runs half a second later. For anything that repeats or needs to be cancelled, use a Timer node instead, and check whether the action is still valid when the function resumes. Stopping a timer alone does not resume code waiting for its timeout.
Cheat Sheet: Quick Reference
# Variables
var x = 10 # Dynamic type
var x: int = 10 # Typed
var x := 10 # Typed by inference
const X = 10 # Constant
@export var x: int = 10 # Editable in Inspector
@onready var x = $Node # Get node reference
# Collections
var a: Array[int] = [1, 2] # Typed array
var d = {"key": "value"} # Dictionary
d.get("key", "default") # Safe read
# Functions
func name(): # Basic function
func name(arg: int): # With parameter
func name() -> int: # Returns a value
# Lifecycle
func _ready(): # Called once when ready
func _process(delta): # Called every frame
func _physics_process(delta): # Called for physics
# Input
Input.is_action_pressed("x") # Is held down?
Input.is_action_just_pressed("x") # Just pressed?
Input.get_axis("left", "right") # Returns -1, 0, or 1
# Signals
signal my_signal # Define
my_signal.emit() # Send
obj.my_signal.connect(func) # Listen
# Common operations
position.x += 10 # Move right
rotation_degrees += 45 # Rotate
scale *= 2 # Double size
queue_free() # Delete this nodeThe Errors You Will Meet First
- Mixed tabs and spaces: pasted code. Select it and use Edit > Convert Indent to Tabs.
- Node not found: a
$Pathwith a typo or wrong case, or a lookup that ran before_ready(). Check the Scene dock spelling and use@onready. - The InputMap action doesn't exist: define the action in Project Settings > Input Map.
- Invalid get index on base Dictionary: a missing key. Use
get(key, default). - Invalid operands String and int: string concatenation with a number. Use
str()or"%s" % value. - Integer division warning:
7 / 2is 3. Make one side a float. - Attempt to call function on a null instance: the variable never got a node. Usually a
$Pathwithout@onready, or a scene that was freed.
What's Next?
You now know enough GDScript to build a small game. Open Godot, create a 2D scene, add a CharacterBody2D, attach the movement script from Part 6, define the four actions in the Input Map, and press F6.
- Build something small: the first tiny game tutorial turns this syntax into a finished collect-and-score game.
- Understand the nodes you are scripting: Understanding Godot's node system.
- Practise the syntax: the code sandbox and cheat sheet are built for exactly this stage.
- Coming from Python or C#? Read GDScript vs Python or the Unity C# crash course for the differences that matter.
Change the movement speed, add a new input action, or connect a second signal listener. Use the error list above when something fails, then check the scene structure before replacing the script.
Frequently asked questions
- How long does it take to learn GDScript?
- The time depends on your programming experience and the game you want to make. Start with variables, functions, and input in one small scene. Then learn node references, signals, and lifecycle callbacks as you add behavior.
- Is GDScript easy to learn?
- GDScript has readable syntax, optional type hints, and editor completion. Learning where code belongs in _ready, _process, and _physics_process takes practice as well as learning the syntax.
- Is GDScript similar to Python?
- It looks similar: indentation, colons, and a comparable feel. It is not Python. GDScript has static types, signals, annotations like @export, and no Python standard library. See the GDScript vs Python article for the differences beginners actually notice.
- Should I learn GDScript or C# for Godot?
- GDScript is a practical starting point when you are learning Godot through its built-in editor. Choose C# when your existing knowledge or compatible libraries make that worthwhile, and check your required export platforms first.
Sources and revision context
Updated September 8, 2026. Replaced exaggerated learning claims with concrete syntax and setup explanations. This revision does not establish a full tutorial runtime test.