GDScript is Python without the baggage. You already know Python (or close enough). Here's what's different, what's surprising, and what you'll actually use. We're going to hit everything you need to be productive in about two screens.
# Every script extends a node type. This one is a script for a Node2D.
extends Node2D
# class_name gives it a global identifier (optional).
class_name Card
# Properties. Typed. Default values.
var letter: String = "A"
var value: int = 1
var is_flipped: bool = false
# @export surfaces the property in the editor Inspector. Massive quality of life.
@export var face_up: bool = true
@export var card_scale: float = 1.0
# @onready grabs a child node after _ready fires. Safer than putting get_node in init.
@onready var label: Label = $LetterLabel
@onready var bg: Sprite2D = $Background
# Constants. SCREAMING_SNAKE. Compile-time.
const MAX_HAND_SIZE: int = 7
# Signals. Pub/sub. Declared at top. Emitted later.
signal card_tapped(card: Card)
signal card_discarded
# Lifecycle hooks.
func _ready() -> void:
label.text = letter
func _process(delta: float) -> void:
# delta is seconds since last frame; ~0.0167 at 60fps.
pass
# Methods. Typed args, typed return.
func play() -> int:
is_flipped = true
card_tapped.emit(self)
return value
# Static methods work as you'd expect.
static func from_letter(l: String) -> Card:
var c := Card.new()
c.letter = l
return c
@export and @onready are the two you'll use constantly. Learn them now, thank yourself later.GDScript is gradually typed. You can omit types and it works. You should not omit types. Type annotations catch errors at parse time, enable editor autocomplete, and make refactors safe.
var x = 5 # untyped; fine but loses autocomplete
var y: int = 5 # explicit
var z := 5 # inferred from literal (preferred when obvious)
var name := "Lexicon" # inferred as String
func draw_card(count: int = 1) -> Array[Card]:
# Array[Card] is a typed array, like List<Card> in C#.
return []
The convention: := when the type is inferable from the right-hand side; : Type = when it isn't or when you want to be explicit. Function signatures always carry types.
# Strings
var s := "Lexicon"
print(s.length()) # 7
print(s.to_lower()) # "lexicon"
print("%s has %d letters" % [s, s.length()]) # formatted
# Arrays โ duck-typed by default, or typed via Array[T]
var hand: Array[Card] = []
hand.append(Card.new())
hand.push_front(Card.new())
var first = hand[0]
hand.shuffle()
# Dictionaries โ like dicts in Python, or JS objects
var letter_values := {
"A": 1, "B": 3, "C": 3, "D": 2,
"E": 1, "F": 4, "G": 2, "H": 4,
}
print(letter_values.get("Q", 10)) # default fallback
letter_values["Z"] = 10
# Dictionary keys can be anything hashable. Typically strings.
if score > 100:
print("high")
elif score > 50:
print("mid")
else:
print("low")
# Ternary โ mind the order. "value if condition else fallback".
var tier := "high" if score > 100 else "low"
# Match โ like a switch, with pattern matching. Godot devs use this a lot.
match card.letter:
"A", "E", "I", "O", "U":
print("vowel")
_:
print("consonant")
# Loops
for i in 10:
print(i) # 0..9
for card in hand:
card.play()
while not deck.is_empty():
draw()
self is optional for your own properties. Usually you just write letter = "A". But when passing yourself to a signal: card_tapped.emit(self).
null and there's freed
Accessing a freed Object raises an error. Check is_instance_valid(node) if you're holding a reference across frames. More on this in lesson 3.
class SubCard: but it's rare. One file, one node script โ treat it that way.
Every script extends a node type. A script extending Node2D can only attach to Node2D or its descendants. Common bases:
| Extends | When |
|---|---|
Node | Pure logic, no transform. State managers, autoloads, systems. |
Node2D | Anything with a 2D position/rotation. Most of your game. |
Control | UI elements. Anchored, layout-aware. Labels, buttons, panels. |
CharacterBody2D | Player or enemy with physics-aware movement. |
RefCounted | Pure data, reference-counted like Python. Card data without a visual. |
Resource | Serializable data. Huge in Godot. We'll cover it in lesson 3. |
# String path. Works, but fragile โ rename the node and it breaks.
var label = get_node("LetterLabel")
var label = $LetterLabel # shorthand for the above
# @onready + $Path โ best for child nodes that always exist.
@onready var label: Label = $LetterLabel
# @export var โ best for cross-tree references. Drag the target in the Inspector.
@export var enemy_hand: Node2D
# get_tree().get_first_node_in_group("player") โ when you want one-of-a-kind.
var player := get_tree().get_first_node_in_group("player")
@onready var x: T = $Path as "component dependency injection, resolved at mount time." If the path is wrong, you get a null reference on ready โ a hard early failure, which is what you want.print("Hello") # output log
print("card: ", card.letter) # multiple args, space-separated
print_debug("with stack trace") # includes file:line
push_warning("this is fishy") # yellow in output
push_error("this is broken") # red
# Breakpoints: set them by clicking the gutter next to a line number.
# F5 runs the project; F6 runs the current scene; F7 steps over.
Node2D. Save as test.tscn.extends Node2D stub._ready:func _ready() -> void:
var hand: Array[String] = ["A", "E", "I", "O", "U"]
hand.shuffle()
for letter in hand:
print("drew: ", letter)
print("total value: ", hand.size())
Next: signals and resources. These are where Godot really diverges from what you're used to.