How: GDScript was created specifically for the Godot Engine, first appearing around 2010 during Godot’s internal development at OKAM Studio.
Who: Designed by Juan Linietsky and the Godot core team.
Why: To provide a tightly integrated, Python-inspired scripting language that compiles fast, has zero external dependencies, and maps directly to Godot’s node/scene architecture — unlike Lua or Python which would require heavy bridging.
Introduction
GDScript is a dynamically-typed, optionally statically-typed scripting language built into Godot. It compiles to bytecode at runtime, supports full OOP, signals, coroutines, lambdas, and first-class integration with every Godot API.
In Godot 4, GDScript received a major overhaul: typed arrays, lambdas, improved type inference, await, and much better performance.
Advantages
Zero setup — built into Godot, no external interpreter needed.
Deep engine integration — signals, nodes, resources are first-class.
Optional static typing for performance and safety.
Hot-reload scripts without restarting the game.
Disadvantages
Not a general-purpose language — only runs inside Godot.
Slower than C++ or C# for CPU-heavy logic.
Smaller ecosystem than Python (no pip packages).
Dynamic typing by default can hide bugs if you skip type hints.
Basics
Hello World & Script Structure
extends Node # every script extends a Godot classfunc _ready() -> void: print("Hello, World!")
extends — declares which Godot class this script inherits.
_ready() — called once when the node enters the scene tree.
print() — outputs to the Godot Output panel.
Comments
# Single line comment## Doc comment — shown in editor tooltip for exported vars/functions## @param amount The damage value## @return Remaining healthfunc take_damage(amount: int) -> int: health -= amount return health
Variables & Constants
# Dynamic typing (inferred)var health = 100var name = "Player"var speed = 3.14var is_alive = true# Static typing (recommended — better performance + editor autocomplete)var health: int = 100var name: String = "Player"var speed: float = 3.14var is_alive: bool = true# Type inference with :=var score := 0 # inferred as intvar label := $Label # inferred as Label node type# Constants (compile-time, cannot be changed)const MAX_HEALTH: int = 200const GRAVITY: float = 980.0const GAME_TITLE := "My Game"
Data Types Table
Type Description Example
null No value null
bool true / false var x: bool = true
int 64-bit integer var x: int = 42
float 64-bit double precision var x: float = 3.14
String UTF-32 text var x: String = "hi"
StringName Hashed string (fast comparison) var x: StringName = &"idle"
NodePath Path to a node var x: NodePath = ^"Player/Sprite"
Vector2 2D vector (x, y) var x: Vector2 = Vector2(1, 0)
Vector2i 2D integer vector var x: Vector2i = Vector2i(3, 4)
Vector3 3D vector (x, y, z) var x: Vector3 = Vector3.UP
Vector3i 3D integer vector var x: Vector3i = Vector3i(1,2,3)
Vector4 4D vector var x: Vector4 = Vector4(1,2,3,4)
Color RGBA color (0.0–1.0) var x: Color = Color.RED
Rect2 2D rectangle (position + size) var x: Rect2 = Rect2(0,0,100,50)
Rect2i Integer rectangle var x: Rect2i = Rect2i(0,0,10,5)
Transform2D 2D transform matrix var x: Transform2D
Transform3D 3D transform matrix var x: Transform3D
Basis 3x3 rotation/scale matrix var x: Basis
Quaternion Rotation quaternion var x: Quaternion
Plane 3D plane (normal + distance) var x: Plane
AABB Axis-aligned bounding box var x: AABB
Array Dynamic typed array var x: Array = [1, "a", true]
Array[T] Typed array (Godot 4) var x: Array[int] = [1, 2, 3]
Dictionary Key-value map var x: Dictionary = {"a": 1}
Callable Reference to a function var x: Callable = func(): pass
Signal Signal reference var x: Signal
PackedArray Packed typed arrays (fast) PackedInt32Array, PackedFloat32Array...
Object Base of all Godot objects var x: Object
Type Casting
# as — safe cast, returns null if it failsvar node = get_node("Player") as CharacterBody2Dif node: node.velocity = Vector2.ZERO# int(), float(), str(), bool() — explicit conversionvar n: int = int(3.99) # 3var f: float = float(5) # 5.0var s: String = str(42) # "42"var b: bool = bool(0) # false# is — type checkif node is RigidBody2D: print("it's a rigidbody")
Operators
# Arithmetic+ - * / % # add, sub, mul, div, modulo** # power (2 ** 8 = 256)# Integer divisionvar result = 7 / 2 # 3 (int division when both are int)var result = 7.0 / 2 # 3.5 (float division)# Relational== != < > <= >=# Logicaland or not # (also: && || ! work too)# Bitwise& | ^ ~ << >># Assignment= += -= *= /= %= **= &= |= ^= <<= >>=# Ternary (inline if)var label = "Adult" if age >= 18 else "Minor"# in — membership testif "sword" in inventory: print("has sword")if 3 in [1, 2, 3, 4]: print("found")
String Operations
var s: String = "Hello, World!"# Basicprint(s.length()) # 13print(s.to_upper()) # HELLO, WORLD!print(s.to_lower()) # hello, world!print(s.strip_edges()) # trim whitespaceprint(s.reverse()) # !dlroW ,olleH# Substring & searchprint(s.substr(7, 5)) # Worldprint(s.find("World")) # 7 (-1 if not found)print(s.contains("Hello")) # trueprint(s.begins_with("He")) # trueprint(s.ends_with("!")) # true# Replace & splitprint(s.replace("World", "Godot")) # Hello, Godot!var parts = "a,b,c".split(",") # ["a", "b", "c"]var joined = ",".join(["a","b","c"]) # "a,b,c"# Format stringsvar msg = "HP: %d / %d" % [health, max_health]var msg2 = "Pos: (%.2f, %.2f)" % [pos.x, pos.y]# String interpolation (Godot 4.3+)# Not yet native — use % or str() for now# Convertvar n = int("42") # 42var f = float("3.14") # 3.14var b = "true".to_lower() == "true"# Multiline stringvar text = """Line oneLine twoLine three"""
# match is GDScript's switch — but more powerfulvar state = "run"match state: "idle": play_animation("idle") "run", "walk": # multiple values play_animation("run") "jump": play_animation("jump") _: # default (wildcard) play_animation("idle")# Match with type checkmatch typeof(value): TYPE_INT: print("integer") TYPE_STRING: print("string") TYPE_ARRAY: print("array")# Match with binding (capture matched value)match point: Vector2(0, 0): print("origin") Vector2(var x, 0): print("on x-axis at ", x) Vector2(var x, var y): print("at ", x, ", ", y)# Match with guard condition (when)match health: var h when h > 75: print("Healthy") var h when h > 25: print("Hurt") _: print("Critical")
for Loops
# Range loopfor i in range(5): print(i) # 0 1 2 3 4for i in range(2, 10, 2): print(i) # 2 4 6 8for i in range(10, 0, -1): print(i) # 10 9 8 ... 1# Iterate arrayvar items = ["sword", "shield", "potion"]for item in items: print(item)# Iterate dictionaryvar stats = {"hp": 100, "mp": 50, "atk": 30}for key in stats: print(key, ": ", stats[key])# Iterate with index (no enumerate, use range)for i in range(items.size()): print(i, ": ", items[i])# Nested loop with break/continuefor i in range(5): if i == 2: continue # skip 2 if i == 4: break # stop at 4 print(i) # 0 1 3
while Loop
var i = 0while i < 5: print(i) i += 1# Infinite loop with breakwhile true: if condition: break
Functions
Declaration & Calling
# Basic functionfunc greet(name: String) -> String: return "Hello, " + name# Void function (no return value)func reset() -> void: health = 100 position = Vector2.ZERO# Default argumentsfunc spawn(x: float = 0.0, y: float = 0.0, scene: String = "Player") -> void: position = Vector2(x, y)# Callingprint(greet("Godot")) # Hello, Godotspawn() # uses defaultsspawn(100.0, 200.0) # override x and y
Return Multiple Values
# GDScript doesn't have tuples — use Array or Dictionaryfunc get_stats() -> Array: return [health, mana, stamina]var stats = get_stats()print(stats[0]) # health# Or use Dictionary for named returnsfunc get_player_info() -> Dictionary: return {"name": player_name, "level": level, "hp": health}var info = get_player_info()print(info["name"])
Variadic-style with Array
# GDScript has no *args — use Array parameterfunc sum(values: Array[int]) -> int: var total := 0 for v in values: total += v return totalprint(sum([1, 2, 3, 4, 5])) # 15
Lambdas (Anonymous Functions)
# Lambda syntax (Godot 4)var add = func(a: int, b: int) -> int: return a + bprint(add.call(3, 4)) # 7# Multiline lambdavar process = func(x: int) -> int: var result = x * 2 return result + 1print(process.call(5)) # 11# Lambda as Callable — used with signals, timers, etc.button.pressed.connect(func(): print("clicked"))# Capture outer variables (closures)var multiplier = 3var scale_fn = func(x: int) -> int: return x * multiplierprint(scale_fn.call(5)) # 15# Sort with lambdavar nums = [3, 1, 4, 1, 5, 9]nums.sort_custom(func(a, b): return a > b) # descendingprint(nums) # [9, 5, 4, 3, 1, 1]
Callable
# Callable wraps any function referencevar fn: Callable = greet # method referenceprint(fn.call("World")) # Hello, World# Callable.bind() — pre-bind argumentsvar greet_alice = greet.bind("Alice")greet_alice.call() # Hello, Alice# Callable.bindv() — bind array of argsvar fn2 = add.bindv([10, 20])fn2.call() # 30# is_valid() — check if callable is validif fn.is_valid(): fn.call("test")# call_deferred() — call on next framefn.call_deferred("deferred")
Coroutines & await
# await pauses execution until a signal fires or coroutine completes# Wait for signalfunc _ready() -> void: await $AnimationPlayer.animation_finished print("animation done")# Wait for timerfunc delayed_spawn() -> void: await get_tree().create_timer(2.0).timeout spawn_enemy()# Wait for next frameawait get_tree().process_frame# Wait for physics frameawait get_tree().physics_frame# Await a coroutine (another func that uses await)func fade_out() -> void: var tween = create_tween() tween.tween_property(self, "modulate:a", 0.0, 1.0) await tween.finishedfunc die() -> void: await fade_out() queue_free()# Return value from coroutinefunc load_data() -> Dictionary: await get_tree().create_timer(0.5).timeout return {"loaded": true}func _ready() -> void: var data = await load_data() print(data["loaded"]) # true
# player.gdextends CharacterBody2Dclass_name Player # registers globally — usable from any script# Member variablesvar health: int = 100var max_health: int = 100var speed: float = 200.0# Called when node enters scenefunc _ready() -> void: print("Player ready!")func take_damage(amount: int) -> void: health = max(0, health - amount) if health == 0: die()func heal(amount: int) -> void: health = min(max_health, health + amount)func die() -> void: queue_free()
extends Node# Define signalssignal health_changed(new_health: int, max_health: int)signal player_diedsignal item_collected(item_name: String, value: int)var health: int = 100var max_health: int = 100func take_damage(amount: int) -> void: health = max(0, health - amount) health_changed.emit(health, max_health) # emit with args if health == 0: player_died.emit()func collect(item: String, val: int) -> void: item_collected.emit(item, val)
Connect & Disconnect
# Connect in codefunc _ready() -> void: health_changed.connect(_on_health_changed) player_died.connect(_on_player_died) # Connect with lambda item_collected.connect(func(name, val): print("Collected: ", name, " worth ", val) ) # Connect one-shot (auto-disconnects after first emit) player_died.connect(_on_player_died, CONNECT_ONE_SHOT) # Connect deferred (fires on next frame, safe for scene changes) player_died.connect(_on_player_died, CONNECT_DEFERRED)func _on_health_changed(new_hp: int, max_hp: int) -> void: $HealthBar.value = float(new_hp) / max_hp * 100func _on_player_died() -> void: get_tree().reload_current_scene()# Disconnecthealth_changed.disconnect(_on_health_changed)# Check if connectedif health_changed.is_connected(_on_health_changed): health_changed.disconnect(_on_health_changed)
Signal as Variable & Await
# Signals are first-class — store and pass themfunc wait_for_signal(sig: Signal) -> void: await sig print("signal fired!")# Await a signal inlinefunc _ready() -> void: await player_died print("player is dead")# Await with timeout patternfunc wait_or_timeout(sig: Signal, timeout: float) -> bool: var timer = get_tree().create_timer(timeout) var result = await Engine.get_main_loop().create_signal_awaiter([sig, timer.timeout]) return result == sig
Connect in Editor
In the editor: select a node → Node panel (right side) → Signals tab → double-click a signal → choose target node and method.
Editor connections are stored in the .tscn file and auto-connected at runtime.
Annotations
@export
extends Node# Basic exports — visible and editable in Inspector@export var speed: float = 200.0@export var health: int = 100@export var player_name: String = "Hero"@export var is_active: bool = true# Export with range slider@export_range(0.0, 100.0) var volume: float = 50.0@export_range(1, 10, 1) var level: int = 1@export_range(0.0, 1.0, 0.01, "or_greater") var opacity: float = 1.0# Export enum (dropdown in Inspector)@export_enum("Idle", "Run", "Attack", "Die") var state: int = 0# Export flags (bitmask checkboxes)@export_flags("Fire", "Water", "Earth", "Air") var elements: int = 0# Export file path@export_file("*.png") var icon_path: String = ""@export_dir var save_dir: String = ""# Export node reference@export var target: Node@export var spawn_point: Node2D# Export resource types@export var texture: Texture2D@export var audio: AudioStream@export var scene: PackedScene# Export color (with/without alpha)@export var tint: Color = Color.WHITE@export_color_no_alpha var bg_color: Color = Color.BLACK# Export typed array@export var waypoints: Array[Vector2] = []@export var enemy_scenes: Array[PackedScene] = []# Export group (organizes Inspector)@export_group("Combat Stats")@export var attack: int = 10@export var defense: int = 5@export_group("") # end group# Export subgroup@export_subgroup("Speed Settings")@export var walk_speed: float = 100.0@export var run_speed: float = 200.0
@onready
extends Node# @onready — assigns value when _ready() is called# Equivalent to: var label; func _ready(): label = $Label@onready var label: Label = $Label@onready var sprite: Sprite2D = $Sprite2D@onready var anim: AnimationPlayer = $AnimationPlayer@onready var timer: Timer = $Timer# With type inference@onready var health_bar := $UI/HealthBar@onready var player := $Player as CharacterBody2D# Safe to use in _ready and all later callbacksfunc _ready() -> void: label.text = "Ready!" anim.play("idle")
@tool
@tool # script runs in the editor, not just at runtimeextends Node2D@export var radius: float = 50.0: set(value): radius = value queue_redraw() # redraw when changed in editorfunc _draw() -> void: draw_circle(Vector2.ZERO, radius, Color(1, 0, 0, 0.3))# Use case: custom editor gizmos, procedural generation previews,# level design helpers, auto-configuration scripts
@static_unload
# Prevents the script's static data from persisting between scene changes@static_unloadclass_name LevelDataextends Nodestatic var cached_map: Dictionary = {}
@warning_ignore
# Suppress specific GDScript warnings@warning_ignore("return_value_discarded")func fire() -> void: shoot_bullet() # return value intentionally ignored@warning_ignore("unused_variable")var debug_counter: int = 0
# Anonymous enum (global constants in this script)enum { IDLE, RUN, JUMP, ATTACK, DIE }var state = IDLE# Named enum (accessed as Type.VALUE)enum State { IDLE, RUN, JUMP, ATTACK, DIE }enum Direction { NORTH = 0, EAST = 90, SOUTH = 180, WEST = 270 }var current_state: State = State.IDLEvar facing: Direction = Direction.NORTH# Use in matchmatch current_state: State.IDLE: play_animation("idle") State.RUN: play_animation("run") State.JUMP: play_animation("jump") _: play_animation("idle")# Enum as dictionaryprint(State.keys()) # ["IDLE", "RUN", "JUMP", "ATTACK", "DIE"]print(State.values()) # [0, 1, 2, 3, 4]print(State.find_key(2)) # "JUMP"# Export enum to Inspector@export var player_state: State = State.IDLE
# Seed (for reproducible results)seed(12345)randomize() # seed from time (call once in _ready)# Random float [0.0, 1.0)var r = randf()# Random float in rangevar r = randf_range(0.5, 2.0)# Random int in range [from, to] inclusivevar r = randi_range(1, 6) # dice roll# Random int (full range)var r = randi()# RandomNumberGenerator (independent seed)var rng = RandomNumberGenerator.new()rng.seed = 42print(rng.randf_range(0.0, 1.0))print(rng.randi_range(1, 100))
Type Checking Utilities
typeof(42) # TYPE_INTtypeof("hello") # TYPE_STRINGtypeof([1,2,3]) # TYPE_ARRAYtypeof(null) # TYPE_NILis_instance_valid(node) # true if node exists and not freedis_nan(value) # true if NaNis_inf(value) # true if infiniteis_zero_approx(0.0001) # falseis_equal_approx(a, b) # float equality with epsilon
Node Utilities
Node References
# $ shorthand — get child node by name$Sprite2D$UI/HealthBar # nested path$"My Node" # name with spaces# get_node — explicitget_node("Sprite2D")get_node("UI/HealthBar")get_node(^"Sprite2D") # NodePath literal# get_node_or_null — safe (returns null if not found)var node = get_node_or_null("MaybeNode")if node: node.do_something()# find_child — search recursively by namevar btn = find_child("StartButton", true, false)# get_parent / get_childrenvar parent = get_parent()var children = get_children()# Traverse treefor child in get_children(): if child is Sprite2D: child.visible = false
Node Lifecycle
# Add / remove nodes at runtimevar bullet = bullet_scene.instantiate()add_child(bullet) # add as childadd_child(bullet, true) # add with readable nameget_parent().add_child(bullet) # add as siblingget_tree().root.add_child(bullet) # add to scene root# Removenode.queue_free() # safe removal (end of frame)node.free() # immediate removal (use carefully)remove_child(node) # detach without freeing# Reparentnode.reparent(new_parent)# Duplicatevar clone = node.duplicate()add_child(clone)
Groups
# Add to group (editor: Node → Groups tab, or code)add_to_group("enemies")add_to_group("collectibles")# Checkis_in_group("enemies")# Removeremove_from_group("enemies")# Call method on all in groupget_tree().call_group("enemies", "freeze")get_tree().call_group_flags(SceneTree.GROUP_CALL_DEFERRED, "enemies", "die")# Get all nodes in groupvar enemies: Array[Node] = get_tree().get_nodes_in_group("enemies")for enemy in enemies: enemy.take_damage(10)
Advanced Patterns
Autoload Singleton Pattern
# game_manager.gd — registered as Autoload in Project Settingsextends Nodesignal score_changed(new_score: int)signal game_overvar score: int = 0var lives: int = 3var high_score: int = 0func add_score(points: int) -> void: score += points if score > high_score: high_score = score score_changed.emit(score)func lose_life() -> void: lives -= 1 if lives <= 0: game_over.emit()func reset() -> void: score = 0 lives = 3# Access from anywhere:# GameManager.add_score(100)# GameManager.score_changed.connect(...)
State Machine Pattern
extends CharacterBody2Denum State { IDLE, RUN, JUMP, ATTACK, HURT, DEAD }var state: State = State.IDLEfunc _physics_process(delta: float) -> void: match state: State.IDLE: _state_idle(delta) State.RUN: _state_run(delta) State.JUMP: _state_jump(delta) State.ATTACK: _state_attack(delta) State.HURT: _state_hurt(delta) State.DEAD: _state_dead(delta)func _change_state(new_state: State) -> void: if state == new_state: return _exit_state(state) state = new_state _enter_state(state)func _enter_state(s: State) -> void: match s: State.IDLE: $AnimationPlayer.play("idle") State.RUN: $AnimationPlayer.play("run") State.JUMP: $AnimationPlayer.play("jump") State.ATTACK: $AnimationPlayer.play("attack")func _exit_state(_s: State) -> void: pass # cleanup if neededfunc _state_idle(_delta: float) -> void: if Input.get_axis("move_left", "move_right") != 0: _change_state(State.RUN) if Input.is_action_just_pressed("jump"): _change_state(State.JUMP)func _state_run(delta: float) -> void: var dir = Input.get_axis("move_left", "move_right") velocity.x = dir * 200.0 if dir == 0: _change_state(State.IDLE) move_and_slide()func _state_jump(delta: float) -> void: if not is_on_floor(): velocity.y += 980.0 * delta else: _change_state(State.IDLE) move_and_slide()func _state_attack(_delta: float) -> void: pass # handled by animation signalfunc _state_hurt(_delta: float) -> void: passfunc _state_dead(_delta: float) -> void: pass
Observer Pattern via Signals
# event_bus.gd — Autoload singleton as global event busextends Nodesignal enemy_killed(enemy_type: String, position: Vector2)signal item_picked_up(item_id: int, player_id: int)signal level_completed(level: int, time: float)# Any script can emit:# EventBus.enemy_killed.emit("goblin", global_position)# Any script can listen:# EventBus.enemy_killed.connect(_on_enemy_killed)
Resource as Data Object
# item_data.gdclass_name ItemDataextends Resource@export var item_name: String = ""@export var description: String = ""@export var damage: int = 0@export var defense: int = 0@export var value: int = 0@export var icon: Texture2D@export_enum("Weapon", "Armor", "Consumable", "Quest") var item_type: int = 0func get_tooltip() -> String: return "%s\n%s\nValue: %d gold" % [item_name, description, value]# Create in editor: right-click FileSystem → New Resource → ItemData# Or in code:# var sword = ItemData.new()# sword.item_name = "Iron Sword"# ResourceSaver.save(sword, "res://data/items/iron_sword.tres")
Object Pooling
# object_pool.gd — reuse objects instead of instantiate/freeclass_name ObjectPoolextends Node@export var scene: PackedScene@export var pool_size: int = 20var _pool: Array[Node] = []func _ready() -> void: for i in pool_size: var obj = scene.instantiate() obj.visible = false obj.process_mode = Node.PROCESS_MODE_DISABLED add_child(obj) _pool.append(obj)func get_object() -> Node: for obj in _pool: if not obj.visible: obj.visible = true obj.process_mode = Node.PROCESS_MODE_INHERIT return obj # Pool exhausted — optionally grow var obj = scene.instantiate() add_child(obj) _pool.append(obj) return objfunc return_object(obj: Node) -> void: obj.visible = false obj.process_mode = Node.PROCESS_MODE_DISABLED
More Learn
Explore the following links for valuable resources, communities, and tools to enhance your skills : -