Skip to content

L2.1 — Classes, extends, class_name, _init & Casting

What you'll learn

  • Treat each script as a class, extend a base, and call the base with super.
  • Register a script as a global type with class_name, and why that unlocks clean typing and casts.
  • Distinguish _init (object created in memory) from _ready (node entered the tree).
  • Test and narrow types with is and as, including the null-guard the safe cast requires.

How it applies

  • An unnamed class forces stringly-typed checks. Without class_name, other scripts cannot refer to your type by name, so code falls back to comparing string names or duck-typing — fragile checks that pass the wrong object and fail far from the cause. A named class lets the compiler do the checking.
  • _init runs before children exist. Code that reaches for a child node in _init finds nothing there — the node tree is not assembled yet — and crashes on a null. Knowing which constructor-like hook runs when is the difference between a clean startup and a launch-time null error.
  • An unchecked cast crashes the same as no cast. node as Enemy returns null when node is not an Enemy; using the result without checking for null just moves the crash one line down. The safe pattern is cast-then-check.
  • Skipping super skips the base's setup. Overriding a method that the base relies on, without calling super, silently drops whatever the base did — a defect that looks like "the parent behavior randomly stopped working."

Concepts

Every script is a class; extends picks the base

You met this in L1.1: a .gd file is a class, and extends names its base. Now the consequences matter. Members you declare (variables, functions) are members of your class; members you inherit come from the base and its ancestors. When you override an inherited method, you can still reach the base version with super:

extends Node

func _ready() -> void:
    super()              # run Node's _ready first (if relevant), then ours
    print("my setup")

func describe() -> void:
    super.describe()     # call the base class's describe(), then extend it
    print("...and more")

super() calls the same method on the base; super.name() calls a specific base method. Override without super and the base's version simply does not run — fine when you mean to replace it, a bug when the base needed to do setup.

class_name — a globally known type

By default a script is anonymous: other scripts can load it by path, but cannot name its type. class_name registers the script as a global type:

extends Node
class_name Health

(The extends and class_name lines may appear in either order.) Now any script can write var h: Health, check thing is Health, and create Health.new() — no preload of the file needed. The type also appears in the editor's Create Node / Create Resource dialog. class_name is what turns a script from "a file at a path" into "a type the whole project knows," and the game books use it for every component and data class (Health, StatBlock, ItemData).

Example

Naming a class is what makes type-safe checks possible elsewhere:

# in health.gd
class_name Health
extends Node

# in any other script — no preload required:
if node is Health:
    node.take_damage(5)

Without class_name, that other script could not say is Health at all; it would have to load the script by path and compare, or guess by checking for a method. The name is the seam that lets the compiler verify the check.

_init versus _ready

Two hooks look like constructors; they fire at different moments.

  • _init() runs when the object is created in memory (Health.new(), or when the engine instantiates the node) — before it is part of the scene tree. Child nodes do not exist yet. Use it for pure in-memory setup that depends only on the object itself.
  • _ready() runs once after the node and all its children have entered the tree (L1.1). This is where child references and signal connections are safe.
func _init() -> void:
    # safe: set up own data
    current = max_health

func _ready() -> void:
    # safe: children exist now
    $Bar.value = current

Reaching for $Bar in _init would fail — the child is not there yet. The full lifecycle ordering is G2.1; the rule to carry now is _init = in memory, _ready = in the tree. A custom Resource or plain data class (no nodes) typically uses _init; a node that touches its children uses _ready.

Inner classes

A script can declare classes inside it for small local helpers:

class Entry:
    var key: String
    var weight: int

var e := Entry.new()
e.key = "rare"

Inner classes are scoped to the file. They are useful for tiny structured records; for anything shared across files, a class_name'd script or a custom Resource (G1.6) is the better home.

is and as — testing and narrowing types

is tests whether a value is of a type and returns a bool. as casts: it returns the value typed as that class, or null if it is not that type (a safe cast — it never crashes):

if node is Enemy:
    # inside here, you know node is an Enemy
    ...

var enemy := node as Enemy
if enemy:                 # null if node was not an Enemy
    enemy.alert()

as is safe precisely because the wrong type yields null instead of an error — but that means you must check for null before using the result. Using enemy without the if enemy: guard re-introduces the crash you were avoiding, just one line later. The game books use this exact pattern (M3.1 reads an overlapping area as a hitbox, guards the null, then uses it).

Walkthrough

Create a new script for this chapter; you can attach it to a fresh Node scene and run with F6.

  1. Write a small class_name'd class — e.g. a script class_name Counter / extends Node with a var count := 0 and a func tick() -> void: count += 1. Save it.
  2. In a second script (your run scene's node), declare var c: Counter = Counter.new(), call c.tick() twice, and print c.count. You used the type by name with no preload — that is class_name working.
  3. Add _init and _ready to Counter, each printing which one ran. Attach Counter to a node in a scene, run, and observe the order (_init then _ready).
  4. Type-narrow: make var thing: Node = c, then if thing is Counter: print("yes"), then var maybe := thing as Counter and guard if maybe: maybe.tick(). Change thing to a plain Node.new() and confirm the as yields null and the guarded block is skipped (no crash).

Optional sanity check

In Counter, try to access a child node inside _init (e.g. $SomeChild) on a scene where that child exists. It fails — the child is not in the tree yet at _init time. Move the access to _ready and it works. That contrast is the _init-versus-_ready rule, demonstrated.

Self-check quiz

Q1 — What does adding class_name Health to a script enable that an anonymous script cannot do?

A. It makes the script run automatically at startup. B. It registers a global type, so other scripts can write var h: Health, thing is Health, and Health.new() without preloading the file. C. It improves performance of the script. D. It makes the script a singleton.

Reveal answer

B. class_name registers the script as a globally known type usable by name across the project (and in the editor's create dialog). A describes an autoload (G1.5), a different mechanism. C is unrelated — naming a class is about visibility, not speed. D confuses class_name with the singleton/autoload pattern.

Q2 — Why does accessing $Bar in _init fail, while it works in _ready?

A. $ is only valid inside _ready syntactically. B. _init runs when the object is created in memory, before its children are in the tree; _ready runs after all children have entered the tree. C. _init cannot access any variables. D. _ready creates the children, so they don't exist until it runs.

Reveal answer

B. _init fires at object creation, before the node is assembled into the tree with its children; _ready fires once the whole subtree is in the tree, so child references are valid. A is false ($ is valid anywhere syntactically; it just resolves to nothing in _init). C is false — _init can use members. D is wrong about who creates children (the scene/engine does, not _ready).

Q3 — var e := node as Enemy. What must you do before calling e.alert()?

A. Nothing — as guarantees e is an Enemy. B. Check if e: (or e != null), because as yields null when node is not an Enemy. C. Wrap it in a match. D. Re-cast with is.

Reveal answer

B. as is a safe cast: it returns null rather than crashing when the type does not match, so you must guard against null before using the result. A misstates the guarantee — as does not assure success. C and D are unnecessary machinery; the idiomatic guard is the null check after the cast.

Integration question

Q4 — open

A component script has class_name Health / extends Node, a func _ready() that does setup, and a subclass class_name ArmoredHealth / extends Health that overrides _ready to also read an armor value — but the armored version's bars never initialize. Separately, another script does node.take_damage(5) directly on a Node that might be a Health, and crashes when it is not. Diagnose both, naming the chapter idea each violates.

Reveal expected answer

Bug 1 — missing super. ArmoredHealth._ready overrides Health._ready without calling super(), so the base class's setup (initializing the bars) never runs; only the armor read does. The fix is to call super() at the top of the override so the base setup still happens, then add the armored behavior. This violates the "skipping super skips the base's setup" point. Bug 2 — unchecked type use. Calling node.take_damage(5) on a Node that may not be a Health is an unguarded assumption about type; when node is some other node, the method does not exist and it crashes. The fix is to narrow first — var h := node as Health; if h: h.take_damage(5) — or guard with if node is Health:. This violates the "an unchecked cast (or unchecked assumption) crashes the same as no cast" point. Both bugs come from treating types casually; class_name, super, and is/as are the tools that make type handling explicit and checked.