PONYλM2Modula-2

C++.CodeCompared.To/GDScript

An interactive executable cheatsheet comparing C++ and GDScript

C++23 (GCC) GDScript 4.5 (Godot 4.5.2)
Where C++ Meets Godot
Hello, World
One line, no entry point, no build — and the reason that matters here is iteration speed, not brevity.
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
print("Hello, World!")
GDScript has no main because the engine owns the entry point: a script attaches to a node and the engine calls into it. print writes to the output panel and the console, appending a newline and converting any value. The point of the language is that this file can be edited while the game is running and reloaded without a rebuild — which for a codebase whose C++ side takes minutes to compile is the whole argument.
Godot is a C++ program you extend
Worth putting first, because it decides how to read everything after it: you are not evaluating GDScript against C++, you are deciding where the line goes.
// Godot's engine is ~1.5 million lines of C++, and there are three // ways your C++ can meet it: // // GDExtension — a shared library the engine loads at runtime, using // godot-cpp. No engine rebuild, no engine fork. // Module — compiled INTO the engine; needs a full rebuild but // can touch anything, including the editor. // Fork — you now maintain a fork. Rarely worth it. // // GDExtension is the one that changed the calculus: since Godot 4 you // can ship a C++ node type that appears in the editor like a built-in. #include <iostream> int main() { std::cout << "the engine, and the nodes you add to it" << std::endl; return 0; }
# GDScript is the engine's own language, and its advantage is not # syntax — it is that it already knows about everything above: # # - every engine class, with editor autocomplete # - the scene tree, signals, resources and the inspector # - hot reload while the game runs # - no build step, no ABI, no crash-the-editor risk # # The cost: roughly 10-40x slower than the equivalent C++, dynamically # typed by default, and it can only do what the engine exposes. print("the gameplay, and the iteration speed")
GDExtension is why this page is worth a C++ programmer's time at all. Before Godot 4 the only way to add C++ was a module compiled into the engine, which meant maintaining an engine build; now a .gdextension file plus a shared library built against godot-cpp registers new node types the editor treats as native. So the practical arrangement mirrors the Lua one: C++ owns the things that must be fast or must touch the platform, GDScript owns the behavior that changes daily — and unlike Lua, both halves see the same object model, which is the subject of the next section.
Object, RefCounted & Node
Three base classes, three lifetimes
This is the first thing to internalize, because it is a decision C++ lets you make per object and Godot makes for you by inheritance.
#include <iostream> #include <memory> struct Plain { int value = 1; }; int main() { // C++ gives you the choice per object, and the type says which. Plain onStack; // scope-bound auto owned = std::make_unique<Plain>(); // single owner auto shared = std::make_shared<Plain>(); // reference counted std::cout << onStack.value << " " << owned->value << " " << shared->value << " count=" << shared.use_count() << std::endl; return 0; }
# Godot decides by BASE CLASS, and you cannot opt out. # # Object — manual. You call free(). No refcount at all. # RefCounted — reference counted, freed automatically at zero. # Node — an Object that also lives in the scene tree, and is # freed by its parent (or by queue_free()). var counted := RefCounted.new() print("refcounted count: ", counted.get_reference_count()) var manual := Object.new() print("manual is valid: ", is_instance_valid(manual)) manual.free() # YOU must do this print("after free: ", is_instance_valid(manual))
Choosing a base class chooses a memory strategy. RefCounted is shared_ptr — the common case, and what Resource derives from. Object is a raw new with a matching free() you must call, and forgetting it leaks. Node is an Object that additionally belongs to a tree, so its parent frees it — which is why you almost never free a node yourself. There is no stack allocation and no value semantics for engine objects: every variable holding one is a reference, exactly as in the Kotlin page.
The dangling reference, and how to check for it
Godot has C++'s dangling-pointer problem — because underneath, it is C++ — and it gives you a way to detect it that C++ has no built-in equivalent for.
#include <iostream> #include <memory> struct Enemy { int health = 100; }; int main() { // A raw pointer to a freed object is undefined behavior with // nothing to test. weak_ptr is the checkable version. auto enemy = std::make_shared<Enemy>(); std::weak_ptr<Enemy> watcher = enemy; std::cout << std::boolalpha << !watcher.expired() << std::endl; enemy.reset(); std::cout << !watcher.expired() << std::endl; return 0; }
# A freed Object leaves every reference to it DANGLING — and unlike # C++, there is a built-in way to ask. var enemy := Node.new() print(is_instance_valid(enemy)) enemy.free() print(is_instance_valid(enemy)) # Which is why queue_free() exists: it defers the free to the end of # the frame, so nothing mid-frame is holding a reference that dies # underneath it. Prefer it for anything in the tree.
is_instance_valid() asks whether an object reference still points at a live object, which is weak_ptr::expired() without needing to have set up a weak_ptr in advance. It is the standard defense when holding a reference to a node that might have been freed — a target that died, a UI element that closed. queue_free() is the other half: it defers destruction to the end of the frame so nothing currently executing loses its object mid-call. The rule is queue_free() for anything in the tree, free() only for a bare Object you created and own.
Ref<T> is shared_ptr with the count inside
Every resource — a texture, a mesh, a material, a script — is handed around this way, so this is the ownership model most engine code actually deals with.
#include <iostream> #include <memory> #include <string> struct Material { std::string name; }; int main() { auto first = std::make_shared<Material>(Material{"shared"}); auto second = first; // two handles, one object second->name = "renamed through the other handle"; std::cout << first->name << std::endl; std::cout << "handles: " << first.use_count() << std::endl; return 0; }
# A Resource is reference counted, so a variable holding one is a # handle, never a copy. var first := Resource.new() first.resource_name = "shared" var second := first # two handles, one object second.resource_name = "renamed through the other handle" print(first.resource_name) print("handles: ", first.get_reference_count()) # weakref() holds one WITHOUT counting, for a back-reference that # must not keep its target alive. var observer := weakref(first) print("count is unchanged: ", first.get_reference_count()) print("still there: ", observer.get_ref() != null)
The count lives inside the object rather than in a separate control block, which is the one structural difference from shared_ptr: there is no make_shared versus shared_ptr(new T) distinction to worry about, and a raw pointer to a RefCounted can always be turned back into a counted handle. In C++ the type is Ref<T> and it does exactly what you expect on copy, assignment and destruction. weakref() is weak_ptr: it observes without owning, and get_ref() answers null once the last real handle is gone. The trap is the same trap: two objects holding counted handles to each other never reach zero, and Godot has no cycle collector.
Where RAII stops
A C++ programmer reaches for a destructor to close a file, release a lock or unregister a handler, and none of those habits transfer unchanged.
#include <iostream> struct Noisy { ~Noisy() { std::cout << "destructor ran at scope exit" << std::endl; } }; int main() { { Noisy scoped; std::cout << "inside the block" << std::endl; } std::cout << "after the block" << std::endl; return 0; }
# There is no scope-exit hook. A local going out of scope drops a # reference, and that is all — freeing is a call you make. var immediate := Object.new() immediate.free() # gone on this line print("freed at once: ", is_instance_valid(immediate)) # queue_free() is the one to use on anything in the scene tree: it # defers the deletion to the end of the frame, so code still holding # the node this frame does not read freed memory. var deferred := Node.new() deferred.queue_free() print("still valid this frame: ", is_instance_valid(deferred))
GDScript has no destructor you can rely on for cleanup ordering. A RefCounted is collected when the last handle drops, but you cannot say when and you get no callback to hang work off; _notification(NOTIFICATION_PREDELETE) exists and runs, but it runs during teardown and is not the place for anything elaborate. So cleanup is explicit: disconnect what you connected, and free what you allocated with Object.new(). free() deletes immediately and invalidates every other handle to that object, which is why queue_free() is the default for nodes — it waits until the end of the frame, by which time nothing is mid-way through using it. Signals are the exception worth knowing: a connection is dropped automatically when either end is freed.
A parent owns its children
This is the rule that makes the scene tree work, and it is worth stating plainly because nothing in the call site says a transfer is happening.
#include <iostream> #include <memory> #include <vector> struct Node { std::vector<std::unique_ptr<Node>> children; ~Node() { std::cout << "a node was destroyed" << std::endl; } }; int main() { auto parent = std::make_unique<Node>(); parent->children.push_back(std::make_unique<Node>()); std::cout << "children: " << parent->children.size() << std::endl; parent.reset(); // takes the child with it return 0; }
# add_child() TRANSFERS ownership. The parent now decides how long # the child lives, and freeing the parent frees the whole subtree. var parent := Node.new() var child := Node.new() parent.add_child(child) print("children: ", parent.get_child_count()) print("the child knows its parent: ", child.get_parent() == parent) parent.free() print("the child went with it: ", is_instance_valid(child))
A node is owned by its parent, a resource by its reference count, and a bare Object by you. Those three sentences cover almost every lifetime question on this engine. add_child() is a unique_ptr move in everything but spelling: after it, the child is the parent's business, and remove_child() hands ownership back — a node removed and not re-parented is a leak until someone frees it. The owner property is a separate idea and a common confusion: it marks which nodes get written out when a scene is saved, and has nothing to do with memory.
Dynamic by Default
Static typing is opt-in, and worth opting into
GDScript is dynamically typed and has grown a static type system on top, and the difference between the two spellings is larger than it looks.
#include <iostream> #include <string> int main() { int health = 100; // the type is mandatory auto damage = 30; // auto still resolves at compile time std::string name = "goblin"; // health = name; // error: no viable conversion std::cout << name << " " << (health - damage) << std::endl; return 0; }
var health = 100 # untyped: a Variant var damage := 30 # := INFERS and then FIXES the type var name: String = "goblin" # explicit annotation health = "now a string" # legal, because health is untyped # damage = "nope" # ERROR: damage is statically int print(name, " ", 100 - damage) print(typeof(health) == TYPE_STRING)
A bare var holds a Variant and can be reassigned to anything. := infers the type and fixes it, and : Type states it — both give compile-time checking, editor autocomplete, and a real speed improvement, because the compiler can skip the Variant dispatch. The style advice in every Godot codebase of any size is to annotate everything, and the project setting untyped_declaration can be promoted to a warning or an error to enforce it. For a C++ programmer the useful framing is that := is auto and a bare var is std::any.
Variant, and What It Costs
Variant is the engine's universal type
Variant is the single most important type in Godot, and it is the thing your C++ code will spend most of its boundary effort converting to and from.
#include <iostream> #include <string> #include <variant> int main() { // std::variant is a closed set you declare, and visiting it // is a compile-time dispatch. std::variant<int, std::string> value = 42; std::cout << std::get<int>(value) << std::endl; value = std::string("now a string"); std::cout << std::get<std::string>(value) << std::endl; return 0; }
# Variant is ONE type that can hold any of ~40 engine types, and the # dispatch happens at runtime on every operation. var value = 42 print(value, " ", type_string(typeof(value))) value = "now a string" print(value, " ", type_string(typeof(value))) value = Vector2(1, 2) print(value, " ", type_string(typeof(value))) value = [1, 2, 3] print(value, " ", type_string(typeof(value)))
Every untyped GDScript value, every signal argument, every exported property and every engine API that crosses the script boundary is a Variant — a tagged union of about forty types, roughly 24 bytes, dispatched at runtime. It is what makes the dynamic half of the engine possible and it is where the performance goes: an untyped a + b must check both tags and pick an implementation, where a typed int + int compiles to an integer add. In C++ you meet it as the godot::Variant class, and the same rule applies — convert at the boundary, then work in real types.
String, StringName & NodePath
One string type, always UTF-8 aware
A C++ programmer carries a well-earned suspicion of string length, and Godot answers the question differently enough to be worth the first row of the section.
#include <iostream> #include <string> int main() { std::string name = "Ada"; std::string greeting = "hello, " + name; std::cout << greeting << std::endl; std::cout << greeting.size() << " bytes" << std::endl; std::cout << greeting.substr(7) << std::endl; return 0; }
var name := "Ada" var greeting := "hello, " + name print(greeting) print(greeting.length(), " characters") print(greeting.substr(7)) # Length is in CHARACTERS, not bytes, and the difference shows the # moment the text is not ASCII. var accented := "café" print(accented.length(), " characters, ", accented.to_utf8_buffer().size(), " bytes")
String is a single type for all text and it counts characters, so "café".length() is 4 where std::string would say 5 bytes. There is no std::wstring / u16string / u32string decision to make and no encoding conversion at the boundary; when you genuinely need bytes, to_utf8_buffer() gives you a PackedByteArray and says so in its name. Strings are also immutable-by-value with copy-on-write underneath, so passing one around costs a pointer copy and mutating a shared one silently makes the copy for you.
StringName: comparison without the compare
This type has no equivalent in the standard library, and it exists for a reason that will be familiar: comparing the same short string thousands of times a frame.
#include <iostream> #include <string> // The usual C++ answer is to intern by hand: keep a table, hand out // small integer ids, and compare those instead of the characters. int main() { std::string a = "ui_accept"; std::string b = "ui_accept"; std::cout << std::boolalpha; // Same characters, different objects — the comparison walks them. std::cout << (a == b) << std::endl; std::cout << (a.data() == b.data()) << std::endl; return 0; }
# &"..." makes a StringName: interned once, compared by identity. var action := &"ui_accept" print(action, " is a ", type_string(typeof(action))) print(&"ui_accept" == action) # It compares equal to the plain string too, so it is not a separate # vocabulary you have to learn — just a faster representation. print(action == "ui_accept") # Node names, signal names, animation names, input actions and method # names are all StringName in the engine's own signatures. var by_name := {} by_name[&"jump"] = 1 print(by_name)
A StringName is an interned string — created once, stored once, and compared by pointer rather than by character. That is why the engine's own signatures use it everywhere a short fixed name is passed repeatedly: node names, signal names, input actions, animation names, method names in call(). The &"..." prefix builds one at parse time so the interning cost is paid before the game runs; writing a plain "ui_accept" where a StringName is expected works, but converts on every call. The trade is the one you would expect from a hand-rolled intern table: creation is more expensive, and the entry lives until the program ends.
NodePath: a parsed path, not a string
Reaching for another node looks like passing a string, and the type in the signature says otherwise for a reason worth understanding before writing much scene code.
#include <iostream> #include <sstream> #include <string> #include <vector> int main() { // Splitting on every lookup is exactly what NodePath avoids. std::string path = "Player/Sprite2D"; std::vector<std::string> parts; std::stringstream stream(path); for (std::string part; std::getline(stream, part, '/'); ) { parts.push_back(part); } std::cout << parts.size() << " names, first is " << parts[0] << std::endl; return 0; }
# ^"..." parses the path once, at load time. var path := ^"Player/Sprite2D" print(path.get_name_count(), " names, first is ", path.get_name(0)) print(path.get_concatenated_names()) print("absolute? ", path.is_absolute()) # A subpath after ':' addresses a PROPERTY rather than a node, which # is how animation tracks and property tweens name their target. var target := ^"Player/Sprite2D:modulate:a" print(target.get_subname_count(), " subnames: ", target.get_concatenated_subnames())
A NodePath is split into its segments when it is constructed, so get_node(^"Player/Sprite2D") walks a pre-parsed list instead of scanning text. The ^"..." literal builds it at parse time, so a path written in a script costs nothing at run time — passing a plain string builds one on every call. The :property subpath form is the part with no obvious analogue in C++: one value can address a node, a property on it, and even a component of that property, which is what lets an animation track or a Tween take a single argument saying what to animate. $Player/Sprite2D is shorthand for get_node(^"Player/Sprite2D").
Formatting and the utilities you would have written
Text handling is where a scripting language earns its place next to C++, and the gap is wider than the syntax makes it look.
#include <format> #include <iostream> #include <string> int main() { std::cout << std::format("{} scored {} ({:.1f}%)", "Ada", 97, 96.5) << std::endl; std::cout << std::format("frame_{:04}.png", 7) << std::endl; // Splitting and joining are still yours to write, or to assemble // out of <ranges>. std::string line = "ada,grace,alan"; std::size_t at = line.find(','); std::cout << line.substr(0, at) << std::endl; return 0; }
# % takes a format string and an array, in printf's vocabulary. print("%s scored %d (%.1f%%)" % ["Ada", 97, 96.5]) print("frame_%04d.png" % 7) # Splitting, joining, trimming and case are methods on the string. var line := " ada,grace,alan " var names := line.strip_edges().split(",") print(names) print(", ".join(names).to_upper()) print("42".is_valid_int(), " ", "42".to_int() + 1) print(String.humanize_size(1048576))
The % operator is printf's format vocabulary with an array on the right — %s, %d, %.1f, %04d all mean what you expect, and %% is a literal percent. String.format() is the named-placeholder alternative when the argument order would be hard to follow. What is genuinely different is the surface area: split, join, strip_edges, pad_zeros, similarity, is_valid_int, humanize_size and about a hundred more are methods on the type, so the small text utilities that accumulate in every C++ codebase are already written. C++23's std::format closed the formatting half of this gap; the utility half is still yours.
Arrays, Dictionaries & Packed
Array is a vector of Variants
Both containers map across, and the cost model does not: an Array element is a Variant, not an int.
#include <iostream> #include <map> #include <string> #include <vector> int main() { std::vector<int> readings{10, 20, 30}; // 4 bytes per element std::map<std::string, int> stock{{"gold", 5}}; readings.push_back(40); std::cout << readings.size() << " " << readings[0] << " " << stock["gold"] << " " << sizeof(readings[0]) << std::endl; return 0; }
var readings := [10, 20, 30] # Array of Variant var stock := {"gold": 5} # Dictionary, insertion-ordered readings.append(40) print(readings.size(), " ", readings[0], " ", stock["gold"]) # A TYPED array checks on insert and is faster, but still stores # Variants underneath. var typed: Array[int] = [1, 2, 3] print(typed, " ", typed.size())
A GDScript Array is a vector of Variants, so a million integers costs roughly 24 bytes each with a tag check on every access — perhaps six times the memory of a std::vector<int> and none of the cache behavior. Array[int] adds insert-time checking and better codegen but does not change the storage. Dictionary is hashed and, like Python's dict, preserves insertion order. When the cost matters the answer is the next row, which is Godot's equivalent of reaching for a real typed buffer.
Packed arrays are the real ones
This is the row that decides whether a piece of gameplay code needs to be C++ at all, so it is worth knowing before you reach for GDExtension.
#include <iostream> #include <vector> int main() { // std::vector<float> is contiguous floats. This is the baseline // every other language on this anchor is compared against. std::vector<float> vertices{1.0f, 2.0f, 3.0f, 4.0f}; std::cout << vertices.size() << " elements, " << sizeof(vertices[0]) << " bytes each" << std::endl; return 0; }
# PackedFloat32Array IS a contiguous float buffer — the same memory # layout as std::vector<float>, with no Variant per element. var vertices := PackedFloat32Array([1.0, 2.0, 3.0, 4.0]) print(vertices.size(), " elements, 4 bytes each") # The family covers what the engine actually passes around in bulk: var bytes := PackedByteArray([1, 2, 3]) var indices := PackedInt32Array([0, 1, 2]) var points := PackedVector2Array([Vector2(0, 0), Vector2(1, 1)]) print(bytes.size(), " ", indices.size(), " ", points.size())
The Packed*Array family stores real machine types contiguously with no Variant boxing, which is why every bulk engine API takes one — mesh vertices, image data, index buffers, network packets. They are also copy-on-write, so passing one is cheap and mutating a shared one duplicates it, exactly like the Swift page's arrays. Reaching for a packed array is often the difference between "this needs a C++ node" and "this is fine in GDScript", and it is a much cheaper move than adding a build step.
Array[int] against std::vector<int>
A typed array is the closest thing GDScript has to a template instantiation, and knowing where the check happens tells you what it is worth.
#include <algorithm> #include <iostream> #include <vector> int main() { std::vector<int> scores{3, 1, 2}; std::sort(scores.begin(), scores.end()); for (int score : scores) std::cout << score << " "; std::cout << std::endl; // The element type is checked when the code is compiled. // scores.push_back("nope"); // would not build std::cout << scores.size() << " elements" << std::endl; return 0; }
var scores: Array[int] = [3, 1, 2] scores.sort() print(scores) print(scores.size(), " elements") # The element type is carried by the array itself and checked when # something is put in, so the error arrives at the assignment rather # than three frames later where the value is read. print("typed as int: ", scores.get_typed_builtin() == TYPE_INT) var untyped := [1, "two", Vector2.ZERO] print("untyped holds anything: ", untyped)
An Array[int] is one Array type carrying its element type as data, not a distinct type per element as std::vector<int> is — so there is no monomorphization, no code generated per instantiation, and the check happens when a value goes in rather than when the code is compiled. That still catches the bug near where it was written, which is most of the value. The storage is unchanged: every element is a Variant, so a typed array of a million integers costs the same memory as an untyped one. When that matters, the answer is PackedInt32Array, and the row two down explains why.
Dictionary against unordered_map
The lookup you write a dozen times a day, and three details differ from the container it replaces.
#include <iostream> #include <string> #include <unordered_map> int main() { std::unordered_map<std::string, int> tally{{"ada", 1}, {"grace", 2}}; tally["alan"] = 3; std::cout << tally.size() << " entries" << std::endl; std::cout << std::boolalpha << tally.contains("grace") << std::endl; auto found = tally.find("nobody"); std::cout << (found == tally.end() ? -1 : found->second) << std::endl; return 0; }
var tally: Dictionary[String, int] = {"ada": 1, "grace": 2} tally["alan"] = 3 print(tally.size(), " entries") print(tally.has("grace")) print(tally.get("nobody", -1)) # a default instead of an iterator # Insertion order is kept, which unordered_map does not promise and # map only fakes by sorting. print(tally.keys()) # Untyped, any Variant can be a key — including a Vector2, with no # hash function to write. var by_position := {Vector2(0, 0): "origin", Vector2(1, 1): "corner"} print(by_position[Vector2(1, 1)])
Three things a C++ programmer should notice. Dictionary keeps insertion order, so iterating one is reproducible in a way std::unordered_map explicitly is not — worth having when a save file or a network packet is built by walking it. get(key, default) replaces the find-compare-to-end dance, and there is no iterator to invalidate. And any Variant can be a key, including a Vector2 or a whole array, with no std::hash specialization to write — the cost being that hashing goes through Variant rather than through a type you chose. Typing it as Dictionary[String, int] checks keys and values on insertion, the same bargain the typed array makes.
map, filter and reduce built in
The same three operations, and the difference is entirely in what happens between them.
#include <iostream> #include <numeric> #include <ranges> #include <vector> int main() { std::vector<int> scores{8, 3, 9, 1}; auto big = scores | std::views::filter([](int one) { return one > 2; }) | std::views::transform([](int one) { return one * 10; }); for (int one : big) std::cout << one << " "; std::cout << std::endl; std::cout << std::accumulate(scores.begin(), scores.end(), 0) << std::endl; return 0; }
var scores: Array[int] = [8, 3, 9, 1] print(scores.filter(func(one): return one > 2).map(func(one): return one * 10)) print(scores.reduce(func(total, one): return total + one, 0)) print(scores.max(), " ", scores.min(), " ", scores.slice(1, 3))
The C++ pipeline is lazy: views::filter and views::transform compose into one type and nothing is computed until the loop pulls, so no intermediate vector is ever built. GDScript's filter and map are eager — each returns a new array, so a three-stage pipeline over ten thousand elements allocates twice on the way through, and each element crosses the interpreter boundary through a Callable. For a list of items in a menu that is irrelevant and the readability is worth having; inside _process over a large array, a plain for loop is measurably faster and is what the engine's own GDScript is written with.
Classes, Files & Inheritance
One file is one class
GDScript ties types to files, which changes how a Godot codebase is laid out and is the first structural surprise for a C++ programmer.
#include <iostream> #include <string> // A header and a source file, several classes each if you like, and // the file name has no relationship to the type name. class Player { public: explicit Player(std::string name) : name_(std::move(name)) {} std::string describe() const { return "player " + name_; } private: std::string name_; }; int main() { Player hero("Ada"); std::cout << hero.describe() << std::endl; return 0; }
# In player.gd — the FILE is the class, and its path is its identity. class_name Player # optional: registers a global name extends RefCounted # the base class; defaults to RefCounted var name: String = "" func _init(new_name: String) -> void: name = new_name func describe() -> String: return "player " + name # Elsewhere: # var hero := Player.new("Ada") # via class_name # var hero := load("res://player.gd").new("Ada") # via path
Every .gd file is a class, implicitly extending RefCounted unless it says otherwise, and its resource path is its identity. class_name additionally registers a global name so the editor autocompletes it and it appears in the "create node" dialog. Nested classes exist (class Inner: inside a file) but are uncommon. There is no header, no forward declaration and no separate compilation — but there is a cyclic-dependency problem, since two files that class_name each other can fail to load, which is the Godot equivalent of an include cycle.
Single inheritance, from an engine class
Godot has single inheritance and no interfaces, and the base class is almost always an engine type rather than one of yours.
#include <iostream> #include <memory> class Entity { public: virtual ~Entity() = default; virtual double health() const = 0; }; class Enemy : public Entity { public: double health() const override { return 40.0; } }; int main() { std::unique_ptr<Entity> entity = std::make_unique<Enemy>(); std::cout << entity->health() << std::endl; return 0; }
# In enemy.gd — one base class, and in practice it is an ENGINE class. extends CharacterBody2D # ... which extends PhysicsBody2D # ... which extends CollisionObject2D # ... which extends Node2D → Node → Object var health: float = 40.0 func take_damage(amount: float) -> void: health -= amount if health <= 0.0: queue_free() # the tree frees it at end of frame # There are no interfaces and no multiple inheritance. Shared # behavior is composition: add a child node that does the job.
A script extends an engine class and thereby becomes that class with extra behavior — so your enemy.gd is a CharacterBody2D, with its physics, collision and transform already present. That is very different from a C++ hierarchy you design, and it means the interesting question is which engine class to start from rather than how to structure your own tree. With no interfaces, shared behavior is composition: a node whose only job is a health bar, added as a child. Duck typing fills the rest — has_method("take_damage") is the idiomatic check.
Signals
Signals are the observer pattern, built in
Signals are the mechanism a Godot codebase is actually organized around, and C++ has no standard equivalent at all.
#include <functional> #include <iostream> #include <vector> // C++ has no built-in observer. You write it, or you adopt a library // (Boost.Signals2, Qt's signals/slots, an EnTT dispatcher). class Emitter { public: void onDamaged(std::function<void(int)> handler) { handlers_.push_back(std::move(handler)); } void damage(int amount) { for (const auto& handler : handlers_) { handler(amount); } } private: std::vector<std::function<void(int)>> handlers_; }; int main() { Emitter emitter; emitter.onDamaged([](int amount) { std::cout << "took " << amount << std::endl; }); emitter.damage(30); return 0; }
# A signal is a first-class language construct. Normally declared at # file scope with `signal damaged(amount: int)`; created here at # runtime so the example can actually run. var emitter := Node.new() emitter.add_user_signal("damaged", [{"name": "amount", "type": TYPE_INT}]) emitter.connect("damaged", func(amount): print("took ", amount)) emitter.emit_signal("damaged", 30) # Connections survive the emitter being passed around, are inspectable # in the editor, and disconnect automatically when either end is freed. print("connections: ", emitter.get_signal_connection_list("damaged").size()) emitter.free()
The normal spelling is signal damaged(amount: int) at file scope, then emitter.damaged.connect(handler) and damaged.emit(30) — the example above builds one at runtime only so it can execute inside the runner. What makes signals more than a hand-rolled observer is the integration: connections are visible and editable in the editor, they are saved with the scene, and they are severed automatically when either object is freed, so the dangling-callback bug that plagues C++ observer implementations does not arise. The design rule Godot teaches is "call down, signal up" — a parent calls its children's methods, a child signals its parent.
Callable is a bound method as a value
Callable is GDScript's std::function, with the receiver bound in — and it is what every signal connection actually stores.
#include <functional> #include <iostream> struct Counter { int total = 0; void add(int amount) { total += amount; std::cout << total << std::endl; } }; int main() { Counter counter; // Binding a member function to an instance: std::bind, or a lambda. std::function<void(int)> bound = [&counter](int amount) { counter.add(amount); }; bound(5); bound(7); return 0; }
var counter := Node.new() counter.set_meta("total", 0) # A Callable is a method BOUND to an object, as an ordinary value. var add := func(amount: int) -> void: counter.set_meta("total", counter.get_meta("total") + amount) print(counter.get_meta("total")) add.call(5) add.call(7) # bind() pre-binds trailing arguments, which is what signal # connections use to pass extra context. var addFive := add.bind(5) addFive.call() counter.free()
A Callable holds an object and a method name, or a lambda, and can be passed, stored and compared. .bind() pre-binds trailing arguments, which is how pressed.connect(on_pressed.bind(button_index)) passes context a signal does not carry — the pattern you will use constantly in UI code. Note it also knows whether its object is still alive: calling a Callable whose object was freed produces an error rather than undefined behavior, which is the same safety story as is_instance_valid.
The Engine Calls You
You do not own the loop
This is the inversion of control that defines engine work, and it is the same shape whether your code is GDScript or C++.
#include <iostream> // In a C++ engine YOU write the loop, and everything is explicit. int main() { bool running = true; int frame = 0; while (running) { // your loop // pollInput(); update(delta); render(); frame += 1; if (frame >= 3) { running = false; } } std::cout << "ran " << frame << " frames" << std::endl; return 0; }
# In enemy.gd — the engine owns the loop and calls INTO you. extends Node2D func _ready() -> void: # Once, after the node and its children enter the tree. print("ready") func _process(delta: float) -> void: # Every rendered frame. delta is seconds since the last one. position.x += 100.0 * delta func _physics_process(delta: float) -> void: # Fixed timestep (60Hz by default) — where physics belongs. pass func _input(event: InputEvent) -> void: # Only when there is input to handle. pass
The callbacks to know: _ready once when the node and its children are in the tree, _process every rendered frame with a variable delta, _physics_process on a fixed timestep where anything physics- or determinism-sensitive belongs, and _input/_unhandled_input for events. The trap for a C++ programmer is _init versus _ready: _init runs at construction when the node has no parent, no children resolved and no tree, so anything touching get_node() or a sibling belongs in _ready. A GDExtension class overrides exactly the same virtuals from C++.
The scene tree is the program structure
Everything in Godot is a node in one tree, and that single decision replaces several you would make yourself in a C++ engine.
#include <iostream> #include <memory> #include <string> #include <vector> // A C++ engine's object graph is whatever you build: an ECS registry, // a component hierarchy, a flat array of systems. You decide, and you // decide how things find each other. struct GameObject { std::string name; std::vector<std::unique_ptr<GameObject>> children; }; int main() { GameObject root{"World", {}}; root.children.push_back(std::make_unique<GameObject>(GameObject{"Player", {}})); std::cout << root.name << " has " << root.children.size() << " child" << std::endl; return 0; }
# Godot's answer is fixed: a TREE of nodes, and it is also the # serialization format, the editor view and the lookup mechanism. var root := Node.new() root.name = "World" var player := Node.new() player.name = "Player" root.add_child(player) print(root.name, " has ", root.get_child_count(), " child") print("path: ", root.get_path_to(player)) # Lookup is by path, checked at runtime: # get_node("Player") or $Player or %Player (unique name) print("found: ", root.get_node("Player").name) root.free() # frees the whole subtree
The tree is the object graph, the scene file format, the editor hierarchy and the lookup namespace all at once — $Player/Sprite2D is sugar for get_node("Player/Sprite2D"). Freeing a node frees its whole subtree, which is why node ownership is rarely something you think about. The cost is that lookup is by path and therefore runtime-checked: renaming a node in the editor silently breaks every $Path that referenced it, with no compile error. The mitigations are unique names (%Name), @exported NodePaths set in the inspector, and signals instead of reaching across the tree.
@export and the Editor
@export puts a field in the inspector
This is the feature that most changes who can work on the game, and it has no C++ counterpart short of building an editor yourself.
// A tunable value in C++ is a constant, a config file you parse, or // a hot-reloadable data blob you built yourself. Changing it means a // rebuild, or writing the loading code. // // constexpr float kMoveSpeed = 300.0f; // rebuild to change // float moveSpeed = config.getFloat("move_speed", 300.0f); // you wrote this #include <iostream> int main() { constexpr float moveSpeed = 300.0f; std::cout << "speed " << moveSpeed << " (rebuild to change)" << std::endl; return 0; }
# In player.gd — one annotation, and a designer can tune it while # the game runs, per instance, saved with the scene. extends CharacterBody2D @export var move_speed: float = 300.0 @export_range(0.0, 1.0) var friction: float = 0.1 @export var target: NodePath # picked in the inspector @export var bullet_scene: PackedScene # dragged in from the file tree @export_enum("Idle", "Patrol", "Chase") var behavior: int = 0 # The value in the inspector OVERRIDES the default, per instance.
@export makes a script variable editable in the Godot editor's Inspector — the panel a designer sees when they select the node, not anything that appears in the shipped game. The property is typed, and the annotation constrains what can be entered — @export_range bounds it to a numeric range, @export_enum to a fixed set of names, NodePath to a node in the scene, PackedScene to a scene file. The value is saved per instance in the scene file, so ten enemies can each have different speeds without ten scripts. For a C++ programmer the significance is organizational rather than technical: it moves tuning out of the code and out of the programmer's queue. A GDExtension class gets the same thing by calling ClassDB::bind_method and ADD_PROPERTY.
Functions, Lambdas & Callables
Lambdas, and the capture rule
GDScript lambdas capture by value, which is the opposite of C#'s and Kotlin's and worth knowing before you rely on one.
#include <iostream> #include <vector> int main() { std::vector<int> readings{5, 3, 9, 1}; int threshold = 4; // The capture list decides: by value or by reference, per variable. int above = 0; for (int value : readings) { if ([threshold](int candidate) { return candidate > threshold; }(value)) { above += 1; } } std::cout << above << std::endl; return 0; }
var readings := [5, 3, 9, 1] var threshold := 4 # No capture list: a lambda captures by VALUE, at creation time, and # cannot see later changes to the captured variable. var isAbove := func(candidate: int) -> bool: return candidate > threshold var above := 0 for value in readings: if isAbove.call(value): above += 1 print(above) threshold = 100 # the lambda still sees 4 print(isAbove.call(9))
A GDScript lambda copies the values it references at the moment it is created, so it is a snapshot rather than a live view — threshold = 100 after the fact changes nothing. That makes it safe (nothing dangles, nothing surprises you later) and means it cannot be used as an accumulator the way a C++ [&] lambda can; mutate an object's property instead. Note also that a lambda is invoked with .call() rather than (), because it is a Callable value rather than a function name.
Duck typing, and has_method
With no interfaces and single inheritance from engine classes, Godot leans on runtime capability checks — which is a real trade against C++'s compile-time answer.
#include <iostream> #include <memory> #include <string> // A template accepts any type with the right shape, checked at // instantiation. A base class accepts a declared hierarchy. template <typename Target> void hit(Target& target) { target.takeDamage(10); } struct Enemy { int health = 40; void takeDamage(int amount) { health -= amount; std::cout << health << std::endl; } }; int main() { Enemy enemy; hit(enemy); return 0; }
var enemy := Node.new() enemy.set_meta("health", 40) # No interfaces, so the idiom is to ASK. has_method is Godot's # structural check, and it is everywhere in real projects. if enemy.has_meta("health"): enemy.set_meta("health", enemy.get_meta("health") - 10) print(enemy.get_meta("health")) # The usual shape with a real script attached would be: # if target.has_method("take_damage"): # target.take_damage(10) print("has_method('queue_free'): ", enemy.has_method("queue_free")) enemy.free()
has_method() is the idiom, and it is structural typing evaluated at the moment of the call: anything with a take_damage can be hit, regardless of its class. That is flexible in exactly the way a template is, and it fails in exactly the way a template does not — a renamed method is a silent no-op rather than a compile error. Static typing helps where it can (var target: Enemy gives real checking), and the practical compromise most projects reach is typed variables for things you own and has_method at the boundaries where you do not.
await
await suspends until a signal fires
This is the feature that most changes how sequenced gameplay is written, and it costs one keyword.
#include <chrono> #include <iostream> #include <thread> // C++20 coroutines exist but need a promise type and a scheduler, and // there is no std::task in C++23. In an engine the ordinary answer is // a state machine you advance every frame. int main() { int state = 0; for (int frame = 0; frame < 3; frame += 1) { if (state == 0) { std::cout << "walking" << std::endl; state = 1; } else if (state == 1) { std::cout << "opening" << std::endl; state = 2; } else { std::cout << "done" << std::endl; } } return 0; }
# await suspends the function until a signal fires, then resumes it # with its locals intact — so a cutscene reads as straight-line code: # # func open_door() -> void: # animation.play("walk") # await animation.animation_finished # animation.play("open") # await get_tree().create_timer(2.0).timeout # print("done") # # No state machine, no per-frame bookkeeping. The function simply # stops and continues later. print("walking") print("opening") print("done")
await suspends the function until the given signal emits, then resumes it exactly where it stopped with its local state preserved — the same mechanism as Lua's coroutines and Python's await, and the reason cutscenes, dialogue and multi-step abilities are written as ordinary code in Godot rather than as state machines. The trap is that a function containing await becomes a coroutine, so its caller gets a signal-like object rather than the return value unless the caller also awaits. And an awaiting function whose node is freed mid-wait simply never resumes, which is a leak of intent if not of memory.
Failure Without Exceptions
There is no throw
This is the single largest difference in how the two languages handle something going wrong, and it shapes the signature of every function you write.
#include <iostream> #include <stdexcept> int halve(int value) { if (value % 2 != 0) throw std::invalid_argument("needs an even number"); return value / 2; } int main() { std::cout << halve(8) << std::endl; try { std::cout << halve(7) << std::endl; } catch (const std::invalid_argument& problem) { std::cout << "caught: " << problem.what() << std::endl; } std::cout << "the program kept going" << std::endl; return 0; }
# No throw, no catch, no stack unwinding. A function that cannot do # its job says so in its return value and reports the reason. func halve(value: int) -> Variant: if value % 2 != 0: push_error("halve() needs an even number, got %d" % value) return null return value / 2 print(halve(8)) print(halve(7)) print("the program kept going")
GDScript has no exceptions at all — no throw, no try, no unwinding, and therefore nothing to make exception-safe. A function that can fail returns a value saying so, which is why so much of the engine's API returns an Error code or a nullable object. push_error() writes to the error stream and shows in the debugger without stopping anything, so it reports rather than aborts. The practical consequence for someone arriving from C++: there is no RAII to protect and no noexcept to reason about, but there is also no compiler telling you that a failure was ignored, so the discipline of checking the returned value has to come from you.
The Error enum, everywhere
With no exceptions to carry a failure upward, one enum does the job across the whole engine — and it is worth learning by name.
#include <cerrno> #include <cstdio> #include <cstring> #include <iostream> int main() { std::FILE* handle = std::fopen("/tmp/example-settings.ini", "w"); std::cout << "open: " << (handle ? "OK" : std::strerror(errno)) << std::endl; if (handle) { std::fputs("width=1280\n", handle); std::fclose(handle); } std::FILE* missing = std::fopen("/tmp/not-here-at-all.ini", "r"); std::cout << "missing: " << (missing ? "OK" : std::strerror(errno)) << std::endl; return 0; }
# Almost every engine call that can fail returns an int from the # Error enum. OK is zero, so a failure is simply non-zero. var settings := ConfigFile.new() settings.set_value("window", "width", 1280) var saved := settings.save("user://example-settings.cfg") print("save: ", error_string(saved)) var reader := ConfigFile.new() print("load: ", error_string(reader.load("user://example-settings.cfg"))) print("width: ", reader.get_value("window", "width")) print("missing: ", error_string(reader.load("user://not-here-at-all.cfg")))
The Error enum is Godot's errno: about fifty named values, OK is zero, and error_string() turns one into something readable. The convention is consistent enough to rely on — if a method can fail and has nothing else to return, it returns Error — which makes if resource.save(path) != OK: the shape most engine-facing code takes. Where a method must return a value and can fail, it returns null or an empty result instead, and that is the case worth being careful about: nothing warns you when a returned null is used as if it were an object. user:// is the writable per-project directory, resolved by the engine rather than by a path you assemble.
ERR_FAIL_COND_V and its family
Engine code has a house style for arguments that cannot be right, and reading any of Godot's C++ means recognizing it on sight.
// Engine and GDExtension code guards with macros rather than with // exceptions or asserts — the whole engine is built with exceptions // off, so a bad argument has to return. #include <godot_cpp/classes/node.hpp> using namespace godot; double Damage::apply(const Ref<Attack>& attack, double armor) { // Bail out with a value, and log file, line and function. ERR_FAIL_COND_V_MSG(attack.is_null(), 0.0, "apply() needs an attack"); ERR_FAIL_COND_V(armor < 0.0, 0.0); // A condition that should be impossible, kept in release builds. CRASH_COND(attack->get_power() < 0.0); // Report and carry on, rather than returning. WARN_PRINT_ONCE("balance pass still pending"); return attack->get_power() * (1.0 - armor); }
# The script-side equivalents. assert() is compiled OUT of a release # export, so it is for catching your own mistakes during development, # never for validating input that ships. func apply(attack: Object, armor: float) -> float: assert(armor >= 0.0, "armor cannot be negative") if attack == null: push_error("apply() needs an attack") return 0.0 return 100.0 * (1.0 - armor) print(apply(Object.new(), 0.25)) print(apply(null, 0.25))
The macro family is the engine's answer to having neither exceptions nor a way to abort cleanly: ERR_FAIL_COND_V(condition, value) returns value and logs file, line and function; ERR_FAIL_INDEX and ERR_FAIL_NULL are the common specializations; _MSG variants add a sentence; CRASH_COND is for the genuinely impossible and stays in release builds; and WARN_PRINT_ONCE reports without returning. Writing GDExtension code means adopting it, because the engine calls into your code from places that cannot handle a longjmp or a thrown object. On the script side, assert() is the closest thing — and its one crucial property is that a release export strips it entirely, so anything the shipped game must still check has to be an ordinary if.
Threads and the Pool
GDScript can thread too
A game engine's scripting language is often assumed to be single-threaded by nature, and this one is not — though what it can safely touch is narrow.
#include <iostream> #include <thread> long long heavy(int rounds) { long long total = 0; for (int index = 0; index < rounds; ++index) total += index; return total; } int main() { long long answer = 0; std::thread worker([&] { answer = heavy(100000); }); worker.join(); std::cout << "sum: " << answer << std::endl; return 0; }
func heavy(rounds: int) -> int: var total := 0 for index in rounds: total += index return total # Thread takes a Callable rather than a function object, and # wait_to_finish() is join() — it returns whatever the callable # returned, so there is no captured variable to write into. var worker := Thread.new() worker.start(heavy.bind(100000)) print("sum: ", worker.wait_to_finish())
Thread is a real operating-system thread and wait_to_finish() is join(), with one improvement: it returns the callable's return value, so the result does not have to be smuggled out through a captured variable. bind() is partial application — it produces a Callable with the arguments already attached, which is what Thread.start() takes. The hard limit is what a background thread may touch: the scene tree is not thread-safe, so a worker computes and hands its answer back, and anything that changes a node goes through call_deferred() to run on the main thread. Loading and parsing, pathfinding, procedural generation and save-file writing are what threads are actually used for here.
Mutex, without lock_guard
Every C++ habit around locking is built on the destructor, so the shape of this code is where the missing one is felt most.
#include <iostream> #include <mutex> int main() { std::mutex guard; int counter = 0; { std::lock_guard<std::mutex> held(guard); // released at scope exit counter += 1; } std::cout << "counter: " << counter << std::endl; return 0; }
var guard := Mutex.new() var counter := 0 # No lock_guard, because there is no scope-exit hook to build one on. # The unlock is yours to write, and an early return that skips it is # the bug this shape invites. guard.lock() counter += 1 guard.unlock() print("counter: ", counter)
This is where the absence of RAII actually costs something. There is no lock_guard, no scoped_lock and no way to write one, because nothing runs when a variable goes out of scope — so the unlock() is an ordinary statement that an early return will happily skip. The practical discipline is to keep the locked region to a few lines with no branches in it. Semaphore is there for signalling between threads, and the deadlock rules are the ones you already know. Worth saying plainly: reaching for a mutex in GDScript usually means the design has drifted — the normal shape is a worker that computes a result and hands it back, sharing nothing.
WorkerThreadPool: the engine's own threads
Spawning a thread per piece of work is as wrong here as anywhere else, and the engine already keeps a pool sized to the machine.
#include <iostream> #include <vector> int main() { // std::for_each with a parallel policy is the closest match, and // it still needs a thread pool underneath that you do not control. std::vector<int> results(4); for (std::size_t index = 0; index < results.size(); ++index) { results[index] = static_cast<int>(index * index); } for (int one : results) std::cout << one << " "; std::cout << std::endl; return 0; }
var results: Array[int] = [0, 0, 0, 0] func square(index: int) -> void: results[index] = index * index # add_group_task runs one callable N times across the engine's own # pool — the same pool the engine loads resources on, so nothing new # is spawned and nothing has to be joined by hand. var group := WorkerThreadPool.add_group_task(square, results.size()) WorkerThreadPool.wait_for_group_task_completion(group) print("squares: ", results)
WorkerThreadPool is the engine's own pool, sized from the processor count and shared with the engine's internals, so work handed to it competes with resource loading rather than adding threads on top of it. add_task() queues one callable and hands back an id; add_group_task() runs one callable a fixed number of times with the index as its argument, which is the parallel for loop; both are collected with a matching wait_for_*_completion. The rule from the previous row still applies — the callables must not touch the scene tree — and the elements each one writes must not overlap, which is why indexing into a pre-sized array is the shape to copy.
GDExtension: Your C++ in Godot
Registering a C++ node type
This is the payoff for a C++ programmer, and the reason Godot 4 is worth a second look if you dismissed Godot 3.
// A GDExtension class, using godot-cpp. It becomes a real node type // the editor lists, instantiates and inspects — no engine rebuild. // // #include <godot_cpp/classes/sprite2d.hpp> // #include <godot_cpp/core/class_db.hpp> // // using namespace godot; // // class Spinner : public Sprite2D { // GDCLASS(Spinner, Sprite2D) // the registration macro // // double speed = 1.0; // // protected: // static void _bind_methods() { // ClassDB::bind_method(D_METHOD("set_speed", "value"), &Spinner::set_speed); // ClassDB::bind_method(D_METHOD("get_speed"), &Spinner::get_speed); // ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "speed"), "set_speed", "get_speed"); // } // // public: // void _process(double delta) override { rotate(speed * delta); } // void set_speed(double value) { speed = value; } // double get_speed() const { return speed; } // }; #include <iostream> int main() { std::cout << "a C++ node the editor treats as built-in" << std::endl; return 0; }
# From GDScript the C++ class is indistinguishable from a built-in. # There is no import, no binding call, and no marker: # # var spinner := Spinner.new() # spinner.speed = 4.0 # add_child(spinner) # # It also appears in the "Create New Node" dialog, its `speed` # property shows up in the inspector, and it can be attached to a # scene and saved — because ADD_PROPERTY registered it with the same # ClassDB every engine class uses. print("a C++ node the editor treats as built-in")
The GDCLASS macro plus _bind_methods registers the type with ClassDB — the same registry every built-in node uses — so the editor, the inspector, the scene serializer and GDScript all treat it as native. Overriding _process, _ready and the rest works because they are the same virtuals GDScript overrides. What you get over a module is that the engine is stock: no fork, no engine rebuild, and the extension is a shared library you ship alongside the game. What you give up is hot reload (improving, still fiddly) and crash isolation — a segfault in your extension takes the editor with it.
Variant is the boundary, again
The performance advice for GDExtension is the same as for embedded Lua, and for the same reason.
// Every value crossing into or out of GDScript is a godot::Variant, // and the conversions are where the cost is: // // Variant value = 42; // implicit, from any bound type // int back = value; // implicit, back again // Array items = value; // typed engine containers // // // A bound method's arguments arrive as Variants and are converted // // by the generated marshalling code: // ClassDB::bind_method(D_METHOD("hit", "amount"), &Enemy::hit); // // The design rule is the one every FFI has: cross rarely, with a lot // of work. A per-entity per-frame call from GDScript into C++ spends // more time marshalling than computing. #include <iostream> int main() { std::cout << "convert at the boundary, then use real types" << std::endl; return 0; }
# From the script side the conversion is invisible, which is exactly # why it is easy to design a boundary that costs too much. # EXPENSIVE SHAPE: one crossing per entity, per frame. var entities := [1, 2, 3] var total := 0 for entity in entities: total += entity # imagine each of these was a C++ call # CHEAP SHAPE: one crossing, bulk data in a packed array. var bulk := PackedInt32Array([1, 2, 3]) print(total, " ", bulk.size()) print("convert at the boundary, then use real types")
Every argument and return value crossing the script boundary is converted to and from a Variant, so the cost of a call is dominated by marshalling rather than by your code. That makes the shape of the interface the thing to design: one call that processes a PackedVector2Array of a thousand points is fast, a thousand calls each processing one point is not. Godot's own APIs are shaped this way for exactly this reason, which is why Packed*Array exists at all — and it is worth trying the packed-array version in pure GDScript before concluding you need C++.
Why the API is const Ref<T>& everywhere
Every method in the engine's C++ API takes its arguments the same way, and the reasoning behind it decides how your own extension methods should be declared.
#include <godot_cpp/classes/ref.hpp> #include <godot_cpp/classes/texture2d.hpp> #include <godot_cpp/variant/string.hpp> using namespace godot; // The house style, and every engine signature you will read follows it. void Sprite::set_texture(const Ref<Texture2D>& texture) { // By value would touch the reference count twice for nothing. // Non-const would let this function reseat the caller's handle. _texture = texture; } String Sprite::describe(const String& prefix) const { // Strings and Variants are copy-on-write, so const& costs a // pointer and copying costs an atomic increment. return prefix + _texture->get_path(); }
# GDScript has no const parameters and no references — every # argument is a handle passed by value, and the handle is cheap. var _texture: Texture2D = null func set_texture(texture: Texture2D) -> void: _texture = texture # What it DOES have is const for values that never change, which the # compiler folds at parse time. const MAX_SPEED := 400.0 # ...and read-only collections, which is the closest thing to a # promise that a function will not modify what it was handed. var palette := [Color.RED, Color.BLUE] palette.make_read_only() print(palette.is_read_only())
Taking const Ref<T>& avoids an atomic increment and decrement on every call, which matters because these signatures are crossed thousands of times a frame; the const then says the callee will not reseat the caller's handle. The same reasoning applies to const String& and const Variant&, both copy-on-write. Match the style in your own bindings — _bind_methods reflects the signature you declared, and a by-value Ref<T> parameter is a small cost paid forever. On the script side there is nothing to decide: arguments are handles passed by value, so the caller's variable can never be reseated, but the object is shared and a function that mutates what it was given will be seen doing it. make_read_only() is the nearest thing to a const promise, and it is a run-time flag rather than a compile-time one.
Building and Reloading
SCons, because the engine uses SCons
A GDExtension is a shared library per platform, which means a build system, and the choice was made for you by the engine.
# SConstruct — the whole build file for a GDExtension. # # godot-cpp ships an SConstruct that knows every platform, compiler # and architecture the engine supports, so a project inherits all of # it by loading that one and adding its own sources. env = SConscript("godot-cpp/SConstruct") env.Append(CPPPATH=["src/"]) sources = Glob("src/*.cpp") library = env.SharedLibrary( "demo/bin/libdemo{}{}".format(env["suffix"], env["SHLIBSUFFIX"]), source=sources, ) Default(library) # scons platform=macos target=template_debug # scons platform=web target=template_release # The output goes beside a .gdextension file naming one library per # platform, and the editor loads whichever matches.
# There is no build step. A .gd file IS the artifact, and the engine # compiles it when it loads it — which is what a script does here, at # run time, with the same compiler the editor uses. var generated := GDScript.new() generated.source_code = "\n".join([ "extends RefCounted", "", "func greet(who: String) -> String:", "\treturn \"hello, \" + who", ]) print("compiled: ", error_string(generated.reload())) print(generated.new().greet("world"))
Godot builds with SCons — a Python-based build system — and godot-cpp ships an SConstruct that already encodes every platform, architecture, compiler and debug/release combination the engine supports. Loading it and appending your own sources is the whole build file, which is the argument for not fighting it even if CMake is what you know; community CMake ports exist and are a maintenance commitment. What you build is one shared library per platform you ship, listed in a small .gdextension text file that the editor reads to pick the right one. The script side has no equivalent step at all: the source file is what ships, and compiling it is something the engine does on load — or, as here, whenever you ask.
The bindings are generated from the engine
It is worth knowing where the C++ headers come from, because it explains an upgrade rule that catches people out.
// Nothing in godot-cpp's API is written by hand. The engine dumps its // entire public surface as JSON: // // godot --dump-extension-api extension_api.json // // ...and godot-cpp's build generates a header and a source file for // every class in it, before your code is compiled. #include <godot_cpp/classes/sprite2d.hpp> // generated #include <godot_cpp/variant/utility_functions.hpp> using namespace godot; void describe(Sprite2D* sprite) { // A generated method body looks up a cached function pointer and // calls through the C interface — which is why an engine upgrade // needs a rebuild, not a re-link. UtilityFunctions::print(sprite->get_name()); }
# The same registry those bindings are generated FROM is queryable # while the game is running, because the engine keeps it. print(ClassDB.class_exists("Sprite2D"), " ", ClassDB.get_parent_class("Sprite2D")) print(ClassDB.class_has_method("Node", "add_child")) print(ClassDB.can_instantiate("Sprite2D"), " ", ClassDB.can_instantiate("Mesh")) # The property list is what the inspector is built from — a name, a # type and an editor hint for each one. var properties := ClassDB.class_get_property_list("Node2D", true) print(properties.map(func(one): return one["name"]).slice(0, 4))
The engine can dump its entire public API as JSON, and godot-cpp's build turns that file into a class per header before any of your code compiles — so the bindings always match the engine version they were generated against. That is the source of the rule that surprises people, and it runs in one direction only: an extension built against an older 4.x keeps loading in a newer engine, because the compatibility system holds the older call paths open — but one built against a newer API will not load in an older engine at all, since the entry points it was generated against do not exist there yet. ClassDB is the same registry seen from the other side, alive and queryable while the game runs, which is what lets the editor build an inspector for a class it has never heard of — and what makes your registered extension types indistinguishable from built-in ones.
What hot reload does and does not do
This is the difference that decides how a mixed codebase is actually worked on day to day, and it is worth being honest about.
// A GDExtension is a shared library the editor has already loaded. // Reloading it means unloading every instance of every class it // registered, swapping the file, and putting them back — which the // editor can do, with real limits: // // * the library must be rebuilt while the editor holds no // references it cannot restore // * added or removed properties are restored by name, so a rename // loses the value // * a changed class layout invalidates anything the editor cached // * on Windows the loaded .dll is locked, so the build itself // fails unless the editor is closed or the library is copied // // The reliable workflow is still: close the editor, rebuild, reopen. #include <godot_cpp/classes/node.hpp>
# A script is recompiled from source whenever it is loaded, so # "reloading" is just compiling again — no unloading, no restoring # state, no platform caveats. var first := GDScript.new() first.source_code = "\n".join([ "extends RefCounted", "", "func greet() -> String:", "\treturn \"hello\"", ]) first.reload() print(first.new().greet()) # Edit the source, compile again, and the new behavior is live. var second := GDScript.new() second.source_code = first.source_code.replace("hello", "good evening") second.reload() print(second.new().greet())
This is the real argument for keeping gameplay in GDScript and reserving C++ for the parts that need it. A script is recompiled on load, so changing one and pressing play costs nothing; the editor reloads a changed script while it is running. A GDExtension is a shared library the editor has already mapped, so reloading it means tearing down every instance it registered and rebuilding them — which Godot 4 can do, and which still has sharp edges: renamed properties lose their values, cached editor state is invalidated, and on Windows the loaded library is locked so the build fails before the reload is even attempted. Plan for a close-rebuild-reopen cycle on the C++ side and you will not be disappointed by it.
When to Drop to C++
When GDScript is not enough
Worth stating as an ordering rather than a rule, because the instinct to reach for C++ is usually right about the problem and wrong about the cheapest fix.
// The honest ordering, cheapest intervention first: // // 1. Add static types (:= and : Type) — often 2-4x, costs nothing // 2. Move bulk data into Packed*Arrays — removes Variant boxing // 3. Use the engine's own systems — physics, MultiMesh, // navigation, particles are // already C++ // 4. THEN a GDExtension in C++ — a build step, an ABI, // and a crash risk // // Most "we need C++" conclusions are answered by 1-3. The cases that // genuinely need 4: custom mesh generation, pathfinding over large // graphs, procedural terrain, audio DSP, heavy simulation. #include <iostream> int main() { std::cout << "measure before you add a build step" << std::endl; return 0; }
# The same ordering from the script's side, as code you would # actually change. # 1. Untyped: every operation dispatches on Variant tags. var slow = 0 for i in range(1000): slow += i # 1b. Typed: the compiler emits integer arithmetic. var fast: int = 0 for i in range(1000): fast += i # 2. And for bulk numeric data, a packed array rather than an Array. var buffer := PackedInt32Array() buffer.resize(1000) print(slow, " ", fast, " ", buffer.size())
Typing alone is commonly a two-to-four times improvement and costs nothing but annotations, because it lets the compiler skip Variant dispatch. Packed arrays remove per-element boxing. And a great deal of what looks like it needs C++ is already C++ inside the engine — MultiMeshInstance for thousands of identical objects, the navigation server for pathfinding, AnimationTree for state machines. GDExtension is the right answer for genuinely novel heavy computation, and it is worth remembering that it costs a build step, an ABI to keep in step with the engine version, and the loss of crash isolation.
GDScript the Language
Indentation, and no braces
The syntax is Python-shaped, which is deliberate — Godot wanted a language a non-programmer could read.
#include <iostream> int main() { int health = 30; if (health > 50) { std::cout << "healthy" << std::endl; } else if (health > 0) { std::cout << "hurt" << std::endl; } else { std::cout << "dead" << std::endl; } for (int index = 0; index < 3; index += 1) { std::cout << index << std::endl; } return 0; }
var health := 30 if health > 50: print("healthy") elif health > 0: print("hurt") else: print("dead") for index in range(3): print(index) # match is GDScript's switch, and it patterns rather than compares. match health: 0: print("exactly zero") var other when other > 0: print("positive: ", other) _: print("negative")
Indentation delimits blocks, so the layout and the structure cannot disagree; elif is else if; and range() replaces the three-clause for. match is worth knowing because it is closer to Rust's pattern matching than to C++'s switch: it matches literals, arrays, dictionaries and bound variables with when guards, and it does not fall through. What it does not do is check exhaustiveness. Note that GDScript is Python-like, not Python — no list comprehensions, no decorators, no multiple inheritance, and range() returns a real array.
Where the line goes
Worth ending on the same question the Lua page ends on, because the answer has the same shape and one important difference.
// What stays in C++, and why: // // Custom heavy computation: mesh generation, terrain, DSP, // large-graph pathfinding, simulation // Anything needing a platform API the engine does not expose // Anything where Variant marshalling would dominate // An existing C++ codebase you are bringing to Godot // // And what it costs to put it there: a build step, an ABI tied to // the engine version, no hot reload, and a segfault that takes the // editor with it. #include <iostream> int main() { std::cout << "the heavy lifting, and the risk that comes with it" << std::endl; return 0; }
# What belongs in GDScript, and why: # # Gameplay, UI, state, sequencing — anything that changes daily # Anything a designer should be able to tune or a modder extend # Anything worth hot-reloading rather than rebuilding # The glue that wires your C++ nodes into scenes # # Unlike every other target on this anchor, GDScript is not competing # with C++ for the same work — it is the layer above it, in the same # program, using the SAME object model. That last part is what makes # the boundary cheaper here than Lua's: no separate type system to # bridge, just Variant at the seam. print("the behavior, and the iteration speed")
Like Lua, GDScript is a component of a C++ program rather than an alternative to one, so the question is where the line goes rather than which language wins. The difference is that Godot's two halves share an object model: a GDExtension class and a GDScript class both extend engine types, both override the same virtuals, both register properties with the same ClassDB, and both are nodes in the same tree. That makes the boundary substantially cheaper to cross conceptually than an embedded scripting language's — you are not bridging two type systems, only converting through Variant at the seam.
const, enum and read-only collections
Three things a C++ programmer will look for immediately, and one of them behaves differently enough to catch you out.
#include <array> #include <iostream> enum class Facing { Left, Right }; int main() { constexpr double gravity = 9.81; constexpr std::array<int, 3> steps{1, 2, 3}; std::cout << gravity << " " << static_cast<int>(Facing::Right) << std::endl; std::cout << steps.size() << std::endl; return 0; }
const GRAVITY := 9.81 enum Facing { LEFT, RIGHT } print(GRAVITY, " ", Facing.RIGHT) print(Facing.keys()) # enums know their own names # A const array is a constant HANDLE, not a constant array — the # object it names can still be modified unless it is frozen. var steps := [1, 2, 3] steps.make_read_only() print(steps.is_read_only())
const is resolved when the script is parsed, so it is closer to constexpr than to a runtime constant — it costs nothing at run time and cannot be reassigned. An enum is a plain set of integers like an unscoped C++ enum, with one convenience C++ lacks: keys() returns the names, because they survive into the running program. The one to be careful about is the third: const on an array or dictionary freezes the handle, not the contents, so the object it names can still be modified — make_read_only() is what actually freezes the contents, and it is checked at run time rather than by the compiler.
Drawing It, Side by Side
Building a Control tree
The GDScript column below runs in a real Godot engine, and the menu under it is that engine drawing — click an entry and it answers. The C++ column is the same menu as a GDExtension class; read them as one program in two spellings, because that is what they are.
#include <godot_cpp/classes/button.hpp> #include <godot_cpp/classes/control.hpp> #include <godot_cpp/classes/label.hpp> #include <godot_cpp/classes/v_box_container.hpp> #include <godot_cpp/core/class_db.hpp> using namespace godot; class MainMenu : public Control { GDCLASS(MainMenu, Control) protected: static void _bind_methods() {} public: void _ready() override { Vector2 pane = get_viewport_rect().size; VBoxContainer *column = memnew(VBoxContainer); column->set_position(Vector2(28, 22)); column->set_size(pane - Vector2(56, 44)); column->add_theme_constant_override("separation", 10); Label *chosen = memnew(Label); chosen->set_text("Pick one — the buttons work"); chosen->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER); for (const char *caption : { "Continue", "New game", "Options", "Quit" }) { Button *entry = memnew(Button); entry->set_text(caption); entry->set_v_size_flags(Control::SIZE_EXPAND_FILL); entry->connect("pressed", callable_mp_lambda(this, [chosen, caption]() { chosen->set_text(String("You chose ") + caption); })); column->add_child(entry); } column->add_child(chosen); add_child(column); } };
extends Control func _ready() -> void: var pane := get_viewport_rect().size var column := VBoxContainer.new() column.position = Vector2(28, 22) column.size = pane - Vector2(56, 44) column.add_theme_constant_override("separation", 10) var chosen := Label.new() chosen.text = "Pick one — the buttons work" chosen.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER chosen.add_theme_font_size_override("font_size", 18) for caption in ["Continue", "New game", "Options", "Quit"]: var entry := Button.new() entry.text = caption entry.size_flags_vertical = Control.SIZE_EXPAND_FILL entry.add_theme_font_size_override("font_size", 20) entry.pressed.connect(func(): chosen.text = "You chose %s" % caption) column.add_child(entry) column.add_child(chosen) add_child(column)
memnew(VBoxContainer) and VBoxContainer.new() call the same allocator and produce the same engine object; column->add_child(entry) and column.add_child(entry) are the same method. The differences are all C++'s own: GDCLASS registers the type with the engine's reflection so the editor can list and instantiate it, _bind_methods() is where anything the engine or GDScript needs to call by name gets declared, and the Button * is a raw pointer whose lifetime belongs to its parent — add_child is what makes that true, and forgetting it leaks. A Node subclass is the one place in Godot where a raw new is normal C++.
A spirograph, in either language
Below the widget layer a CanvasItem emits drawing commands directly. Both columns trace the same hypotrochoid and pick its gear ratio at random, so every press of the run button is a different design — one closed figure, and then it stops.
#include <godot_cpp/classes/control.hpp> #include <godot_cpp/core/class_db.hpp> #include <godot_cpp/variant/utility_functions.hpp> using namespace godot; class Spirograph : public Control { GDCLASS(Spirograph, Control) PackedVector2Array trail; PackedColorArray tints; Vector2 pane; double phase = 0.0, reach = 0.0, hue = 0.0; int teeth = 0, wheel = 0; protected: static void _bind_methods() {} public: void _ready() override { UtilityFunctions::randomize(); pane = get_viewport_rect().size; const int rings[] = { 7, 11, 13 }; teeth = rings[UtilityFunctions::randi_range(0, 2)]; wheel = UtilityFunctions::randi_range(2, teeth - 1); reach = UtilityFunctions::randf_range(0.5, 1.0); hue = UtilityFunctions::randf(); } void _process(double) override { const double finish = wheel * Math_TAU; // one closed figure, then stop if (phase >= finish) { set_process(false); // for an endless show, re-roll return; // the four values above instead } const double unit = MIN(pane.x, pane.y) * 0.46 / ((teeth - wheel) + wheel * reach); for (int step = 0; step < 12; step++) { phase = MIN(phase + finish / 700.0, finish); const double spin = (teeth - wheel) * phase / wheel; trail.push_back(pane / 2.0 + unit * Vector2( (teeth - wheel) * Math::cos(phase) + wheel * reach * Math::cos(spin), (teeth - wheel) * Math::sin(phase) - wheel * reach * Math::sin(spin))); tints.push_back(Color::from_hsv( Math::fposmod(hue + phase / finish * 0.7, 1.0), 0.6, 1.0)); } queue_redraw(); } void _draw() override { if (trail.size() > 1) draw_polyline_colors(trail, tints, 2.0, true); } };
extends Control var pane := Vector2.ZERO var trail := PackedVector2Array() var tints := PackedColorArray() var phase := 0.0 var teeth := 0 # the fixed ring var wheel := 0 # the wheel rolling inside it var reach := 0.0 # how far out the pen sits var hue := 0.0 func _ready() -> void: randomize() pane = get_viewport_rect().size teeth = [7, 11, 13][randi() % 3] # prime, so any smaller wheel closes wheel = randi_range(2, teeth - 1) reach = randf_range(0.5, 1.0) hue = randf() func _process(_delta: float) -> void: var finish := wheel * TAU # one closed figure, and then stop if phase >= finish: set_process(false) # for an endless show, re-roll the four return # variables above instead of stopping var unit := minf(pane.x, pane.y) * 0.46 / ((teeth - wheel) + wheel * reach) for step in 12: phase = minf(phase + finish / 700.0, finish) var spin := (teeth - wheel) * phase / wheel trail.append(pane / 2.0 + unit * Vector2( (teeth - wheel) * cos(phase) + wheel * reach * cos(spin), (teeth - wheel) * sin(phase) - wheel * reach * sin(spin))) tints.append(Color.from_hsv(fposmod(hue + phase / finish * 0.7, 1.0), 0.6, 1.0)) queue_redraw() func _draw() -> void: if trail.size() > 1: draw_polyline_colors(trail, tints, 2.0, true)
The two columns are the same calls in the same order, which is the point of putting them side by side — but this one is also the clearest case for NOT reaching for C++. The per-frame work here is twelve points and one draw_polyline_colors, so almost no Variant conversion happens at all and the C++ version buys nothing but a build step. The rule that follows: reach for C++ where the work is per element, per frame — a particle solver, a mesh build, a pathfinder over thousands of cells — not where it is a fixed handful of engine calls. Note also what the C++ column has to spell out that GDScript does not: callable_mp_lambda to hand a lambda to a signal, and PackedVector2Array by name where := infers it.
🎮 A game, in about fifty lines
This one is playable — steer with the mouse while the pointer is over the canvas, or with ← and → anywhere. Click to serve again when it ends. It is the previous row plus the two things a game needs and a drawing does not: input, and state that survives from frame to frame.
#include <godot_cpp/classes/control.hpp> #include <godot_cpp/classes/input.hpp> #include <godot_cpp/classes/input_event_mouse_button.hpp> #include <godot_cpp/classes/theme_db.hpp> #include <godot_cpp/core/class_db.hpp> #include <godot_cpp/variant/utility_functions.hpp> #include <vector> using namespace godot; class Breakout : public Control { GDCLASS(Breakout, Control) static constexpr int ROWS = 4; static constexpr int COLUMNS = 10; static constexpr double PADDLE = 100.0; static constexpr double KEY_SPEED = 460.0; Vector2 pane, ball, drift; std::vector<Rect2> bricks; double paddle = 0.0; int cleared = 0; String finished; protected: static void _bind_methods() {} void serve() { bricks.clear(); const Vector2 brick((pane.x - 40.0) / COLUMNS, 16.0); for (int row = 0; row < ROWS; row++) for (int column = 0; column < COLUMNS; column++) bricks.push_back(Rect2( 22.0 + column * brick.x, 28.0 + row * (brick.y + 6.0), brick.x - 4.0, brick.y)); paddle = pane.x / 2.0; ball = Vector2(pane.x / 2.0, pane.y - 70.0); drift = Vector2(UtilityFunctions::randi_range(0, 1) ? 190.0 : -190.0, -250.0); cleared = 0; finished = String(); } public: void _ready() override { pane = get_viewport_rect().size; serve(); } void _gui_input(const Ref<InputEvent> &event) override { Ref<InputEventMouseButton> click = event; if (!finished.is_empty() && click.is_valid() && click->is_pressed()) serve(); } void _process(double delta) override { queue_redraw(); // A stalled frame is not that much gameplay: capping delta slows the // world down for one frame instead of teleporting it across the pane. delta = MIN(delta, 1.0 / 30.0); if (!finished.is_empty()) return; const double steer = Input::get_singleton()->get_axis("ui_left", "ui_right"); const Vector2 pointer = get_local_mouse_position(); if (steer != 0.0) paddle += steer * KEY_SPEED * delta; else if (Rect2(Vector2(), pane).has_point(pointer)) paddle = pointer.x; paddle = CLAMP(paddle, PADDLE / 2.0, pane.x - PADDLE / 2.0); ball += drift * delta; if (ball.x < 8.0 || ball.x > pane.x - 8.0) drift.x = -drift.x; if (ball.y < 8.0) drift.y = Math::abs(drift.y); if (ball.y > pane.y - 30.0 && Math::abs(ball.x - paddle) < PADDLE / 2.0 + 8.0) { drift.y = -Math::abs(drift.y); drift.x = CLAMP(drift.x + (ball.x - paddle) * 4.0, -320.0, 320.0); } for (int index = (int)bricks.size() - 1; index >= 0; index--) { if (bricks[index].grow(8.0).has_point(ball)) { bricks.erase(bricks.begin() + index); drift.y = -drift.y; cleared++; break; } } if (bricks.empty()) finished = "Cleared! Click to play again"; else if (ball.y > pane.y) finished = "Missed — click to play again"; } void _draw() override { for (const Rect2 &brick : bricks) draw_rect(brick, Color::from_hsv(brick.position.y / 200.0, 0.55, 0.95)); draw_rect(Rect2(paddle - PADDLE / 2.0, pane.y - 20.0, PADDLE, 10.0), Color("#87c8f5")); draw_circle(ball, 8.0, Color("#cc342d")); draw_string(ThemeDB::get_singleton()->get_fallback_font(), Vector2(22.0, pane.y - 34.0), vformat("bricks %d/%d mouse or arrow keys", cleared, ROWS * COLUMNS), HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#8fa0b8")); if (!finished.is_empty()) draw_string(ThemeDB::get_singleton()->get_fallback_font(), Vector2(0.0, pane.y / 2.0), finished, HORIZONTAL_ALIGNMENT_CENTER, pane.x, 22, Color("#e6e8ee")); } };
extends Control const ROWS := 4 const COLUMNS := 10 const PADDLE := 100.0 const KEY_SPEED := 460.0 var pane := Vector2.ZERO var bricks: Array[Rect2] = [] var paddle := 0.0 var ball := Vector2.ZERO var drift := Vector2.ZERO var cleared := 0 var finished := "" func _ready() -> void: pane = get_viewport_rect().size _serve() func _serve() -> void: bricks.clear() var brick := Vector2((pane.x - 40.0) / COLUMNS, 16.0) for row in ROWS: for column in COLUMNS: bricks.append(Rect2( 22.0 + column * brick.x, 28.0 + row * (brick.y + 6.0), brick.x - 4.0, brick.y)) paddle = pane.x / 2.0 ball = Vector2(pane.x / 2.0, pane.y - 70.0) drift = Vector2([-1.0, 1.0].pick_random() * 190.0, -250.0) cleared = 0 finished = "" func _gui_input(event: InputEvent) -> void: if finished != "" and event is InputEventMouseButton and event.pressed: _serve() func _process(delta: float) -> void: queue_redraw() # A stalled frame is not that much gameplay: capping delta slows the world # down for one frame instead of teleporting everything across the pane. delta = minf(delta, 1.0 / 30.0) if finished != "": return # Arrow keys steer wherever the pointer is; the mouse only steers while it is # over the canvas, because that is the only time the browser reports it. var steer := Input.get_axis("ui_left", "ui_right") var pointer := get_local_mouse_position() if steer != 0.0: paddle += steer * KEY_SPEED * delta elif Rect2(Vector2.ZERO, pane).has_point(pointer): paddle = pointer.x paddle = clampf(paddle, PADDLE / 2.0, pane.x - PADDLE / 2.0) ball += drift * delta if ball.x < 8.0 or ball.x > pane.x - 8.0: drift.x = -drift.x if ball.y < 8.0: drift.y = absf(drift.y) if ball.y > pane.y - 30.0 and absf(ball.x - paddle) < PADDLE / 2.0 + 8.0: drift.y = -absf(drift.y) drift.x = clampf(drift.x + (ball.x - paddle) * 4.0, -320.0, 320.0) for index in range(bricks.size() - 1, -1, -1): if bricks[index].grow(8.0).has_point(ball): bricks.remove_at(index) drift.y = -drift.y cleared += 1 break if bricks.is_empty(): finished = "Cleared! Click to play again" elif ball.y > pane.y: finished = "Missed — click to play again" func _draw() -> void: for index in bricks.size(): draw_rect(bricks[index], Color.from_hsv(bricks[index].position.y / 200.0, 0.55, 0.95)) draw_rect(Rect2(paddle - PADDLE / 2.0, pane.y - 20.0, PADDLE, 10.0), Color("#87c8f5")) draw_circle(ball, 8.0, Color("#cc342d")) draw_string(ThemeDB.fallback_font, Vector2(22.0, pane.y - 34.0), "bricks %d/%d mouse or arrow keys" % [cleared, ROWS * COLUMNS], HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#8fa0b8")) if finished != "": draw_string(ThemeDB.fallback_font, Vector2(0.0, pane.y / 2.0), finished, HORIZONTAL_ALIGNMENT_CENTER, pane.x, 22, Color("#e6e8ee"))
No physics engine, no collision shapes, no scene file, no assets — Rect2.has_point for the bricks and two sign flips for the walls, which at forty bricks beats a physics server on both speed and readability. Every speed is in pixels per second, so delta keeps the ball crossing the pane at the same rate on a 60Hz laptop and a 144Hz monitor.

Two control schemes, for a reason: a browser reports the pointer only while it is over the canvas, so get_local_mouse_position() freezes where it last saw it. Input.get_axis("ui_left", "ui_right") has no such edge, and both actions ship in Godot's default input map.

Every engine call is identical across the columns; only C++'s own machinery differs — std::vector<Rect2> against a typed Array[Rect2], Ref<InputEventMouseButton> and a validity check against event is InputEventMouseButton. Which answers the obvious question: no, this did not need C++. Ask again at forty thousand bricks.