GDScript for Senior Devs

Module 1 ยท Lesson 2 ยท ~45 min ยท Godot 4.x

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.

The 90% tour

# 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
If this looks like Python with decorators: yes. @export and @onready are the two you'll use constantly. Learn them now, thank yourself later.

Type system

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, arrays, dictionaries

# 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.

Control flow

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()

The stuff that'll bite you

Indentation GDScript is whitespace-significant like Python. The editor uses tabs by default. Don't mix tabs and spaces; Godot will yell at you. The project.godot file has an editor setting for this.
self is explicit In methods, self is optional for your own properties. Usually you just write letter = "A". But when passing yourself to a signal: card_tapped.emit(self).
There's no null โ€” there's 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.
No classes in the Java/C# sense Every script is one class. You can't define multiple classes in one file. You can define inner classes with class SubCard: but it's rare. One file, one node script โ€” treat it that way.

Where scripts attach

Every script extends a node type. A script extending Node2D can only attach to Node2D or its descendants. Common bases:

ExtendsWhen
NodePure logic, no transform. State managers, autoloads, systems.
Node2DAnything with a 2D position/rotation. Most of your game.
ControlUI elements. Anchored, layout-aware. Labels, buttons, panels.
CharacterBody2DPlayer or enemy with physics-aware movement.
RefCountedPure data, reference-counted like Python. Card data without a visual.
ResourceSerializable data. Huge in Godot. We'll cover it in lesson 3.

Getting other nodes

# 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")
Think of @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, breakpoint, debugger

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.

What to do now

  1. In your Godot project, create a new scene. Root node: Node2D. Save as test.tscn.
  2. Attach a script to the root. Let Godot generate the default extends Node2D stub.
  3. Paste this into _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())
  1. Hit F6 to run the current scene. Watch the output at the bottom. Change "U" to something. Re-run. Feel the loop.

Next: signals and resources. These are where Godot really diverges from what you're used to.