Hello World & Output
Hello, World
The first thing you notice is how much has to be there before anything happens: an include for the output stream, and a
main for the program to start in. GDScript has neither because the engine is already running when your script arrives.print("Hello, World!")#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}That difference is the whole shape of the page. A GDScript file is a class the engine loads and calls; a C++ file is a translation unit a compiler turns into machine code, and something else has to decide when it runs.
Printing a value with a message
GDScript formats with the
% operator and an array of values. C++ chains values into the stream with <<, and each one is converted according to its own type.var player_name := "Robi"
var score := 1200
print("%s scored %d" % [player_name, score])#include <iostream>
#include <string>
int main() {
std::string player_name = "Robi";
int score = 1200;
std::cout << player_name << " scored " << score << std::endl;
return 0;
}There is no format string to keep in step with a list, so a mismatched placeholder cannot happen. Inside the engine you would use Godot's own
UtilityFunctions::print instead, which behaves like the print you know.Braces, semicolons, and no indentation rule
GDScript decides what is inside the
if from the indentation. C++ decides from the braces and ignores your indentation completely, so the two can disagree.var temperature := 35
if temperature > 30:
print("hot")
print("drink water")
print("done")#include <iostream>
int main() {
int temperature = 35;
if (temperature > 30) {
std::cout << "hot" << std::endl;
std::cout << "drink water" << std::endl;
}
std::cout << "done" << std::endl;
return 0;
}Because the compiler cannot see your layout, a misleadingly indented line is a readability bug rather than a behavior change — the opposite of the risk you are used to. Every statement also ends in a semicolon, which is the most common thing to forget on the first day.
Variables & Types
A variable holds one kind of thing
A bare
var in GDScript is a Variant that can hold anything and change kind later. C++ has a std::variant, but you must list up front every type it is allowed to be, and say which one you are reading out.var anything = 42
anything = "now a string"
anything = [1, 2, 3]
print(anything)#include <iostream>
#include <string>
#include <variant>
#include <vector>
int main() {
std::variant<int, std::string> anything = 42;
anything = std::string("now a string");
std::cout << std::get<std::string>(anything) << std::endl;
return 0;
}Godot's own C++ has a
Variant class much closer to GDScript's, because the engine needs one to talk to scripts. In ordinary C++ this shape is rare — the language expects you to know what a name holds.auto is inference, not dynamic
C++ spells inference
auto, and it behaves like GDScript's := rather than its bare var: the type is fixed at the declaration and cannot change afterwards.var speed := 200.0
var label := "boss"
print(speed)
print(label)#include <iostream>
#include <string>
int main() {
auto speed = 200.0;
auto label = std::string("boss");
std::cout << speed << std::endl;
std::cout << label << std::endl;
return 0;
}Note the explicit
std::string — a bare "boss" makes auto deduce a C-style character pointer, not a string object. That is a distinction GDScript never asks you to make, and it is the first place auto surprises people.Numbers are still truthy, but say what you mean
C++ does convert a number to
bool, so if (health) compiles — but an empty std::string is true, because the object exists. That is the trap: the rule you know covers half the cases and silently fails the other half.var health := 0
var name := ""
if not health:
print("no health")
if not name:
print("no name")#include <iostream>
#include <string>
int main() {
int health = 0;
std::string name = "";
if (health == 0) {
std::cout << "no health" << std::endl;
}
if (name.empty()) {
std::cout << "no name" << std::endl;
}
return 0;
}Writing the comparison out is the habit worth forming. It costs a few characters and removes an entire category of bug that the compiler will not warn you about.
Integers have a width now
Every GDScript integer is 64-bit. C++ has several widths, and plain
int is usually 32 — smaller than what you have been using without thinking about it.var count := 9223372036854775807
print(count)
print(count is int)#include <cstdint>
#include <iostream>
int main() {
int32_t small = 2147483647;
int64_t big = 9223372036854775807;
std::cout << small << std::endl;
std::cout << big << std::endl;
std::cout << static_cast<int64_t>(small) + 1 << std::endl;
return 0;
}The fixed-width names from
<cstdint> say exactly how many bits you get, which is what engine code uses. Overflow is not reported: it simply wraps, so the static_cast before adding is what keeps the arithmetic in 64 bits.Constants and enums
Both have constants and enums.
constexpr means the value is known while compiling, so it costs nothing at runtime — there is no variable to read.const MAX_HEALTH := 100
enum State { IDLE, WALKING, JUMPING }
var current := State.WALKING
print(MAX_HEALTH)
print(current)#include <iostream>
constexpr int MAX_HEALTH = 100;
enum class State { Idle, Walking, Jumping };
int main() {
State current = State::Walking;
std::cout << MAX_HEALTH << std::endl;
std::cout << static_cast<int>(current) << std::endl;
return 0;
}enum class keeps its values in their own namespace, so State::Walking cannot be confused with another enum's Walking and does not silently convert to a number. GDScript enum values are their integers, which is why the C++ side needs a cast to print one.Strings
Length, case, and slicing
Slicing lines up exactly. The other three do not, and the gap is the point: C++'s standard string is deliberately small, so upper-casing is an algorithm applied to a range rather than a method on the string.
var title := "Crystal Cavern"
print(title.length())
print(title.to_upper())
print(title.substr(0, 7))
print(title.contains("Cave"))#include <algorithm>
#include <iostream>
#include <string>
int main() {
std::string title = "Crystal Cavern";
std::cout << title.size() << std::endl;
std::string shouted = title;
std::transform(shouted.begin(), shouted.end(), shouted.begin(), ::toupper);
std::cout << shouted << std::endl;
std::cout << title.substr(0, 7) << std::endl;
std::cout << (title.find("Cave") != std::string::npos) << std::endl;
return 0;
}find returns a position, and the "not found" answer is the sentinel npos rather than a boolean. Godot's own String class has to_upper() and contains() just as GDScript does, so engine code reads much closer to what you already write than this column does.Building a string in a loop
A GDScript string is immutable, so
+= in a loop builds a new one every pass. A std::string is mutable and can grow in place, and a string stream is the idiom when you are assembling from mixed types.var report := ""
for level in range(1, 4):
report += "level %d cleared\n" % level
print(report.strip_edges())#include <iostream>
#include <sstream>
#include <string>
int main() {
std::ostringstream report;
for (int level = 1; level < 4; level++) {
report << "level " << level << " cleared\n";
}
std::string finished = report.str();
finished.pop_back();
std::cout << finished << std::endl;
return 0;
}Mutability is a real difference rather than a detail: a C++ string you pass to a function can be changed by it, which no GDScript string can. That is the first hint of the ownership questions the Pointers section is about.
Parsing a number out of text
GDScript's
to_int() never fails — unparseable text quietly becomes 0, which is why is_valid_int() exists separately. C++ reports success and the value together.var typed := "42"
var bad := "twelve"
print(typed.to_int())
print(bad.to_int())
print(typed.is_valid_int())
print(bad.is_valid_int())#include <charconv>
#include <iostream>
#include <string>
int main() {
std::string typed = "42";
std::string bad = "twelve";
int value = 0;
auto first = std::from_chars(typed.data(), typed.data() + typed.size(), value);
std::cout << value << " " << (first.ec == std::errc{}) << std::endl;
int second_value = 0;
auto second = std::from_chars(bad.data(), bad.data() + bad.size(), second_value);
std::cout << second_value << " " << (second.ec == std::errc{}) << std::endl;
return 0;
}The failed parse leaves the variable at
0, the same answer GDScript gives — but the ec field says it did not work, so the two cases are distinguishable. This is the same "was it there / what was it" split that shows up everywhere C++ can fail.Numbers & Math
The math you use every frame
GDScript puts these in the global scope. C++ puts them in the
std namespace, and they come from two different headers — arithmetic from <cmath>, comparisons from <algorithm>.print(abs(-7))
print(round(2.6))
print(min(3, 9))
print(clamp(15, 0, 10))
print(sqrt(16.0))#include <algorithm>
#include <cmath>
#include <iostream>
int main() {
std::cout << std::abs(-7) << std::endl;
std::cout << std::round(2.6) << std::endl;
std::cout << std::min(3, 9) << std::endl;
std::cout << std::clamp(15, 0, 10) << std::endl;
std::cout << std::sqrt(16.0) << std::endl;
return 0;
}Inside the engine you would reach for Godot's
Math:: functions instead, which are float-first and carry the game-flavored names you already know, such as lerp and deg_to_rad.float and double are different types
Every GDScript
float is a 64-bit double. C++ has both, and a bare decimal literal is a double — the f suffix is what makes it single precision.var speed := 0.1
var total := speed + 0.2
print(total)
print(is_equal_approx(total, 0.3))#include <cmath>
#include <iostream>
int main() {
float speed = 0.1f;
float total = speed + 0.2f;
std::cout << total << std::endl;
std::cout << (std::fabs(total - 0.3f) < 0.0001f) << std::endl;
return 0;
}Godot builds with 32-bit floats for positions and vectors by default, so the suffix becomes reflex quickly. The comparison is written out because the equality you would reach for silently fails on values that are only nearly equal, which
is_equal_approx exists to hide.Arrays & Vectors
The everyday array
GDScript's
Array becomes std::vector<T> — growable like the one you know, but every element is the same declared type, named in the angle brackets.var loot := ["sword", "shield", "potion"]
loot.append("rope")
print(loot.size())
print(loot[0])
print(loot[-1])
print(loot.has("shield"))#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> loot = {"sword", "shield", "potion"};
loot.push_back("rope");
std::cout << loot.size() << std::endl;
std::cout << loot.front() << std::endl;
std::cout << loot.back() << std::endl;
std::cout << (std::find(loot.begin(), loot.end(), "shield") != loot.end()) << std::endl;
return 0;
}There is no negative indexing:
loot[-1] reads memory before the array and is undefined behavior rather than an error, so back() is the spelling. Searching is a free function over a range rather than a method, which is the pattern the whole standard library follows.Nobody checks the index for you
This is the difference most likely to bite. GDScript reports an out-of-range index; C++'s
[] does not check at all, and reading past the end is undefined behavior — it may print rubbish, or corrupt something, or appear to work.var scores := [10, 20, 30]
print(scores[1])
print(scores.size())
if 7 < scores.size():
print(scores[7])
else:
print("out of range")#include <iostream>
#include <stdexcept>
#include <vector>
int main() {
std::vector<int> scores = {10, 20, 30};
std::cout << scores[1] << std::endl;
std::cout << scores.size() << std::endl;
try {
std::cout << scores.at(7) << std::endl;
} catch (const std::out_of_range& error) {
std::cout << "out of range" << std::endl;
}
return 0;
}at() is the checked form and throws, which is what the example uses. Engine code overwhelmingly uses [] and guarantees the index another way, so the check moves from the language into your reasoning — the single biggest change in what you are responsible for.Sorting by something
The comparator means the same thing in both — true when the first argument comes first. What changes is the data: a
struct with named, typed fields replaces the dictionary of string keys.var enemies := [
{"name": "bat", "hp": 12},
{"name": "golem", "hp": 90},
{"name": "slime", "hp": 30},
]
enemies.sort_custom(func(a, b): return a["hp"] < b["hp"])
for enemy in enemies:
print("%s %d" % [enemy["name"], enemy["hp"]])#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Enemy {
std::string name;
int hp;
};
int main() {
std::vector<Enemy> enemies = {{"bat", 12}, {"golem", 90}, {"slime", 30}};
std::sort(enemies.begin(), enemies.end(),
[](const Enemy& a, const Enemy& b) { return a.hp < b.hp; });
for (const Enemy& enemy : enemies) {
std::cout << enemy.name << " " << enemy.hp << std::endl;
}
return 0;
}That substitution is the most common shape change when porting GDScript data, and it is where the compiler starts helping:
enemy.hp is checked when the library is built, where enemy["hp"] could only fail once the frame ran. The const Enemy& in both the lambda and the loop avoids copying each element — see the Pointers section.Dictionaries & Maps
The everyday dictionary
Both the key type and the value type are declared up front. A GDScript dictionary mixes key types freely; this one cannot, and will not compile if you try.
var stats := {"strength": 12, "agility": 8}
stats["luck"] = 3
print(stats["strength"])
print(stats.has("agility"))
print(stats.size())#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, int> stats = {{"strength", 12}, {"agility", 8}};
stats["luck"] = 3;
std::cout << stats["strength"] << std::endl;
std::cout << stats.contains("agility") << std::endl;
std::cout << stats.size() << std::endl;
return 0;
}🚨 Reading a missing key with
[] does not fail — it inserts one with a default value and returns that. So a typo silently grows the map instead of reporting anything, which is the opposite of what a GDScript habit expects.Reading a key that might not be there
GDScript's
get with a fallback has no direct equivalent. find returns a position, which is either a real entry or the map's end marker, and you choose what to do about it.var config := {"volume": 0.8}
print(config.get("volume", 1.0))
print(config.get("brightness", 1.0))
print(config.has("brightness"))#include <iostream>
#include <string>
#include <unordered_map>
int main() {
std::unordered_map<std::string, double> config = {{"volume", 0.8}};
auto found = config.find("volume");
std::cout << (found != config.end() ? found->second : 1.0) << std::endl;
auto missing = config.find("brightness");
std::cout << (missing != config.end() ? missing->second : 1.0) << std::endl;
std::cout << config.contains("brightness") << std::endl;
return 0;
}Using
find rather than [] is the habit to form, precisely because [] would have inserted brightness as a side effect of asking about it. found->second is the value half of the entry; first is the key.Walking a dictionary
Iterating a GDScript dictionary gives you keys, and you look the value up yourself. C++ gives both halves at once, and the structured binding names them in place.
var loadout := {"head": "helm", "hand": "mace"}
var slots := loadout.keys()
slots.sort()
for slot in slots:
print("%s -> %s" % [slot, loadout[slot]])#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, std::string> loadout = {{"head", "helm"}, {"hand", "mace"}};
for (const auto& [slot, item] : loadout) {
std::cout << slot << " -> " << item << std::endl;
}
return 0;
}This uses
std::map rather than unordered_map because it keeps its keys sorted, so no separate sort is needed — a choice GDScript does not offer. The trade is a slower lookup, which matters only when the map is large or in a hot loop.Control Flow
if / elif / else
The only structural change is that
elif becomes else if — two words, because it really is an else containing another if.var health := 45
if health > 70:
print("healthy")
elif health > 25:
print("hurt")
else:
print("critical")#include <iostream>
int main() {
int health = 45;
if (health > 70) {
std::cout << "healthy" << std::endl;
} else if (health > 25) {
std::cout << "hurt" << std::endl;
} else {
std::cout << "critical" << std::endl;
}
return 0;
}Braces around a single statement are optional and conventionally kept. Omitting them is how the "second line looks like it is inside the if but is not" bug happens, and C++ will not warn you because it never looked at your indentation.
match becomes switch, and it falls through
Two changes at once. A C++
switch works on integers and enums, not on strings, so the state becomes an enum; and each branch needs an explicit break.var state := "walking"
match state:
"idle":
print("standing still")
"walking", "running":
print("moving")
_:
print("unknown")#include <iostream>
enum class State { Idle, Walking, Running };
int main() {
State state = State::Walking;
switch (state) {
case State::Idle:
std::cout << "standing still" << std::endl;
break;
case State::Walking:
case State::Running:
std::cout << "moving" << std::endl;
break;
default:
std::cout << "unknown" << std::endl;
break;
}
return 0;
}🚨 A forgotten
break is not an error — control falls into the next case and runs it too. That is the classic C++ bug GDScript's match cannot have, and stacking two labels as done here is the deliberate use of the same mechanism.Choosing a value inline
GDScript borrows Python's word order — value, condition, alternative. C++ puts the condition first, then the two outcomes separated by
? and :.var health := 12
var status := "hurt" if health < 50 else "fine"
print(status)#include <iostream>
#include <string>
int main() {
int health = 12;
std::string status = health < 50 ? "hurt" : "fine";
std::cout << status << std::endl;
return 0;
}Both arms must produce the same type in C++, which is checked when it compiles. GDScript will happily give you a string on one branch and an integer on the other, and the surprise arrives wherever the result is finally used.
Loops & Iteration
Walking a collection
The range-based
for is GDScript's for x in collection. The extra const and & say how you want each element handed to you.var party := ["Ari", "Bex", "Cyd"]
for member in party:
print(member)#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> party = {"Ari", "Bex", "Cyd"};
for (const std::string& member : party) {
std::cout << member << std::endl;
}
return 0;
}Writing
std::string member instead would copy every element as the loop ran. The reference borrows it and const promises not to change it — a decision GDScript makes for you every time, and one you now make per loop.Counting
range() has no counterpart here. The counted for spells out the same three things — where to start, when to stop, how to step — as separate clauses you can each change independently.for i in range(3):
print(i)
for i in range(2, 8, 2):
print(i)#include <iostream>
int main() {
for (int i = 0; i < 3; i++) {
std::cout << i << std::endl;
}
for (int i = 2; i < 8; i += 2) {
std::cout << i << std::endl;
}
return 0;
}The stop condition is written as the comparison rather than implied, so an inclusive range is a matter of changing
< to <= rather than remembering that range excludes its end.Needing the index too
Here the two columns are nearly the same shape, because the GDScript idiom already loops over indices. The one thing to notice is the loop variable's type.
var waves := ["bats", "slimes", "boss"]
for i in range(waves.size()):
print("wave %d: %s" % [i + 1, waves[i]])#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> waves = {"bats", "slimes", "boss"};
for (size_t i = 0; i < waves.size(); i++) {
std::cout << "wave " << i + 1 << ": " << waves[i] << std::endl;
}
return 0;
}size() returns an unsigned size_t, so an int counter compared against it makes the compiler warn about a signed/unsigned comparison — and, worse, counting down past zero with an unsigned type wraps to a huge number instead of going negative. It is the most common first-week loop bug.Functions
Declaring a function
GDScript writes the return type after an arrow; C++ writes it before the name, where
func used to be. Both annotate parameters the same way round.func damage_after_armor(damage: int, armor: int) -> int:
return max(damage - armor, 0)
print(damage_after_armor(30, 12))
print(damage_after_armor(5, 12))#include <algorithm>
#include <iostream>
int damage_after_armor(int damage, int armor) {
return std::max(damage - armor, 0);
}
int main() {
std::cout << damage_after_armor(30, 12) << std::endl;
std::cout << damage_after_armor(5, 12) << std::endl;
return 0;
}The annotations are optional in GDScript and mandatory here. A function must also be declared before it is used, which is why this one sits above
main — order in the file matters in a way it never does in a GDScript class.Default arguments
Defaults work the same way and must likewise come last. What C++ does not have is a way to name an argument at the call site, so you cannot skip past a default to reach a later one.
func spawn(kind: String, count: int = 1, elite: bool = false) -> String:
return "%d %s%s" % [count, kind, " (elite)" if elite else ""]
print(spawn("bat"))
print(spawn("golem", 3))
print(spawn("slime", 2, true))#include <iostream>
#include <string>
std::string spawn(const std::string& kind, int count = 1, bool elite = false) {
return std::to_string(count) + " " + kind + (elite ? " (elite)" : "");
}
int main() {
std::cout << spawn("bat") << std::endl;
std::cout << spawn("golem", 3) << std::endl;
std::cout << spawn("slime", 2, true) << std::endl;
return 0;
}That is why
spawn("slime", 2, true) has to pass the count it did not care about. The usual C++ answer is a small struct of options, which is more ceremony than GDScript needs for the same job.Lambdas, and what they capture
A GDScript lambda closes over the surrounding scope automatically and is invoked with
.call(). A C++ lambda is invoked like a function, and the square brackets say exactly what it captures — an empty [] captures nothing.var factor := 3
var scale := func(value: int) -> int: return value * factor
print(scale.call(7))
var numbers := [1, 2, 3, 4]
var scaled := numbers.map(func(value): return value * 2)
print(scaled)#include <algorithm>
#include <iostream>
#include <vector>
int main() {
int factor = 3;
auto scale = [factor](int value) { return value * factor; };
std::cout << scale(7) << std::endl;
std::vector<int> numbers = {1, 2, 3, 4};
std::vector<int> scaled(numbers.size());
std::transform(numbers.begin(), numbers.end(), scaled.begin(),
[](int value) { return value * 2; });
for (int value : scaled) {
std::cout << value << std::endl;
}
return 0;
}The capture list is not ceremony:
[factor] copies the value, while [&factor] would keep a reference that becomes dangling if the lambda outlives the variable. That is a bug class GDScript simply does not have, and the brackets are where you decide it.Two functions can share a name
GDScript has no overloading — two functions in one scope cannot share a name, so the type ends up in the name. C++ picks between same-named functions by the argument types.
func describe_number(value: int) -> String:
return "number %d" % value
func describe_text(value: String) -> String:
return "text %s" % value
print(describe_number(7))
print(describe_text("seven"))#include <iostream>
#include <string>
std::string describe(int value) {
return "number " + std::to_string(value);
}
std::string describe(const std::string& value) {
return "text " + value;
}
int main() {
std::cout << describe(7) << std::endl;
std::cout << describe(std::string("seven")) << std::endl;
return 0;
}The explicit
std::string at the second call is doing real work: a bare "seven" is a character pointer, which converts to bool more readily than to std::string, so overload resolution can pick a surprising candidate. Overloading and implicit conversion together are one of C++'s sharpest edges.Pointers & References
Copy, or a second name for the same thing
This is the idea the rest of C++ hangs on. In GDScript an
Array is shared when you assign it and a Vector2 is copied, and you cannot change which. In C++ that is your decision, made with a single character.var first := [1, 2]
var second := first
second.append(3)
print(first)
var a := Vector2(1, 2)
var b := a
b.x = 99
print(a)#include <iostream>
#include <vector>
int main() {
std::vector<int> first = {1, 2};
std::vector<int> copied = first;
copied.push_back(3);
std::cout << first.size() << " " << copied.size() << std::endl;
std::vector<int>& same = first;
same.push_back(3);
std::cout << first.size() << " " << same.size() << std::endl;
return 0;
}Without the
& you get a full copy — a new vector with its own elements. With it you get a second name for the original, so appending through one is visible through the other. GDScript picks per type; C++ picks per variable, per parameter, per return value.How an argument arrives
Both columns show the same asymmetry, but for different reasons. GDScript passes an
Array by reference and a String by value because of what they are; C++ does it because of what the signatures say.func add_loot(bag: Array, item: String) -> void:
bag.append(item)
func rename(label: String) -> void:
label = "changed"
var bag := ["coin"]
var label := "original"
add_loot(bag, "gem")
rename(label)
print(bag)
print(label)#include <iostream>
#include <string>
#include <vector>
void add_loot(std::vector<std::string>& bag, const std::string& item) {
bag.push_back(item);
}
void rename(std::string label) {
label = "changed";
}
int main() {
std::vector<std::string> bag = {"coin"};
std::string label = "original";
add_loot(bag, "gem");
rename(label);
std::cout << bag.size() << std::endl;
std::cout << label << std::endl;
return 0;
}The rule worth memorizing:
const T& for anything you only read and do not want copied, T& when you intend to modify the caller's object, plain T when you want your own copy. Engine code is full of the first, and it is the single most common signature in Godot's C++.A pointer can be nothing
A reference must always name something. A pointer is the one that may be
nullptr, which is why the engine hands you node pointers rather than references.var target = null
print(target == null)
target = "found"
print(target)
print(target.length())#include <iostream>
#include <string>
int main() {
std::string* target = nullptr;
std::cout << (target == nullptr) << std::endl;
std::string found = "found";
target = &found;
std::cout << *target << std::endl;
std::cout << target->size() << std::endl;
return 0;
}Three pieces of punctuation do the work:
&found takes an address, *target reads through it, and target->size() is shorthand for calling a method through it. 🚨 Dereferencing a null pointer is undefined behavior, not an error — this is where a habit of checking replaces GDScript telling you.Memory Becomes Yours
An object dies when its scope ends
GDScript frees a
RefCounted when the last reference goes away, at some point you do not control. C++ destroys an object the instant its scope ends, and runs a destructor you can write.class Torch:
var name: String
func _init(value: String) -> void:
name = value
print("lit %s" % name)
func use_it() -> void:
var torch := Torch.new("pine")
print("using %s" % torch.name)
use_it()
print("after")#include <iostream>
#include <string>
struct Torch {
std::string name;
Torch(std::string value) : name(std::move(value)) {
std::cout << "lit " << name << std::endl;
}
~Torch() {
std::cout << "snuffed " << name << std::endl;
}
};
void use_it() {
Torch torch("pine");
std::cout << "using " << torch.name << std::endl;
}
int main() {
use_it();
std::cout << "after" << std::endl;
return 0;
}That determinism is the feature, and it has a name: RAII. Anything that must be released — a file, a lock, a buffer — is released by a destructor rather than by you remembering. Watch the output order: "snuffed" appears before "after", every run, guaranteed.
Owning something that outlives the scope
When the object must survive the function that made it, someone has to own it.
unique_ptr is that owner: exactly one at a time, and it deletes what it holds when it goes away.class Enemy:
var name: String
func _init(value: String) -> void:
name = value
func spawn(name: String) -> Enemy:
return Enemy.new(name)
var bat := spawn("bat")
print(bat.name)#include <iostream>
#include <memory>
#include <string>
struct Enemy {
std::string name;
Enemy(std::string value) : name(std::move(value)) {}
};
std::unique_ptr<Enemy> spawn(std::string name) {
return std::make_unique<Enemy>(std::move(name));
}
int main() {
std::unique_ptr<Enemy> bat = spawn("bat");
std::cout << bat->name << std::endl;
return 0;
}You will rarely write a bare
new and never a matching delete — that pairing is what leaks when a path returns early. Inside Godot the equivalents are memnew, Ref<T> for reference-counted resources, and for a Node, ownership passes to whatever you add_child it to.Classes & Structs
Declaring a class
The constructor is named after the class rather than
_init, and the odd : strength_(strength) is an initializer list — it constructs the member directly instead of default-building it and then assigning.class Potion:
var strength: int
func _init(value: int) -> void:
strength = value
func describe() -> String:
return "potion of %d" % strength
var small := Potion.new(5)
print(small.describe())#include <iostream>
#include <string>
class Potion {
public:
Potion(int strength) : strength_(strength) {}
std::string describe() const {
return "potion of " + std::to_string(strength_);
}
private:
int strength_;
};
int main() {
Potion small(5);
std::cout << small.describe() << std::endl;
return 0;
}Members are private unless a
public: label says otherwise, the reverse of GDScript where everything is reachable. The const after describe() promises the method changes nothing, and the compiler enforces it — a promise GDScript has no way to make.Inheritance and overriding
GDScript overrides silently — redefining a method in a subclass replaces it. C++ requires the base to say
virtual, and without it the call through an Enemy handle runs the base version, silently.class Enemy:
func speak() -> String:
return "..."
class Bat extends Enemy:
func speak() -> String:
return "screech"
var creature: Enemy = Bat.new()
print(creature.speak())#include <iostream>
#include <memory>
#include <string>
class Enemy {
public:
virtual ~Enemy() = default;
virtual std::string speak() const { return "..."; }
};
class Bat : public Enemy {
public:
std::string speak() const override { return "screech"; }
};
int main() {
std::unique_ptr<Enemy> creature = std::make_unique<Bat>();
std::cout << creature->speak() << std::endl;
return 0;
}🚨 The
virtual ~Enemy() is not optional decoration. Deleting a Bat through an Enemy pointer without a virtual destructor is undefined behavior, and it is the most common way C++ inheritance goes wrong. override is what makes a misspelled method name a compile error rather than a quietly added new one.Templates
Writing something that works for any type
GDScript writes this by giving up on types — a bare
Array and an unannotated fallback. A template keeps the function general and keeps it checked.func first_or_fallback(items: Array, fallback):
return items[0] if items.size() > 0 else fallback
print(first_or_fallback([10, 20], 0))
print(first_or_fallback([], 0))
print(first_or_fallback(["a"], "z"))#include <iostream>
#include <string>
#include <vector>
template <typename T>
T first_or_fallback(const std::vector<T>& items, const T& fallback) {
return items.empty() ? fallback : items.front();
}
int main() {
std::cout << first_or_fallback<int>({10, 20}, 0) << std::endl;
std::cout << first_or_fallback<int>({}, 0) << std::endl;
std::cout << first_or_fallback<std::string>({"a"}, "z") << std::endl;
return 0;
}The compiler generates a separate real function for each type used, so there is no boxing and no runtime dispatch — this is where C++'s generality is free and GDScript's is not. The cost is compile time and error messages that name the instantiation.
Errors & Exceptions
GDScript cannot raise; C++ can
GDScript has no exceptions at all —
push_error writes to the debugger and execution continues, so a failed operation must return something and hope the caller checks.func withdraw(balance: int, amount: int) -> int:
if amount > balance:
push_error("insufficient funds")
return balance
return balance - amount
print(withdraw(100, 30))
print(withdraw(100, 500))#include <iostream>
#include <stdexcept>
int withdraw(int balance, int amount) {
if (amount > balance) {
throw std::runtime_error("insufficient funds");
}
return balance - amount;
}
int main() {
std::cout << withdraw(100, 30) << std::endl;
try {
std::cout << withdraw(100, 500) << std::endl;
} catch (const std::runtime_error& error) {
std::cout << "caught: " << error.what() << std::endl;
}
return 0;
}Look at what the GDScript column is forced to do: return the unchanged balance, which cannot be told apart from a withdrawal of zero. 🚨 The catch, for a Godot developer: Godot itself is built with exceptions disabled, so engine and GDExtension code uses error macros and return codes instead. This is a C++ feature you get in your own libraries and not inside the engine.
A result that might not be there
Returning
null works in GDScript because every value may be null. A C++ std::string cannot be, so "no answer" needs a type that says so.func find_pickup(names: Array, prefix: String):
for name in names:
if name.begins_with(prefix):
return name
return null
var pickups := ["coin", "key", "gem"]
print(find_pickup(pickups, "k"))
print(find_pickup(pickups, "z"))#include <iostream>
#include <optional>
#include <string>
#include <vector>
std::optional<std::string> find_pickup(const std::vector<std::string>& names,
const std::string& prefix) {
for (const std::string& name : names) {
if (name.starts_with(prefix)) {
return name;
}
}
return std::nullopt;
}
int main() {
std::vector<std::string> pickups = {"coin", "key", "gem"};
std::cout << find_pickup(pickups, "k").value_or("nothing") << std::endl;
std::cout << find_pickup(pickups, "z").value_or("nothing") << std::endl;
return 0;
}optional is the honest version of returning null: the absence is in the return type, so the caller cannot forget it exists. value_or supplies the fallback in one call, and this style works inside the engine too, where exceptions are off.GDExtension — Joining the Engine
Registering a class the editor can see
A GDScript file with
class_name is a type the editor lists immediately. A GDExtension class needs three extra things: the GDCLASS macro that registers it with the engine's reflection, a _bind_methods() where anything callable by name is declared, and a build that produces a shared library.extends Node2D
class_name Spinner
func spin(delta: float) -> float:
rotation += delta
return rotation
func _ready() -> void:
print(spin(0.5))
print(spin(0.25))#include <godot_cpp/classes/node2d.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
using namespace godot;
class Spinner : public Node2D {
GDCLASS(Spinner, Node2D)
protected:
static void _bind_methods() {}
public:
double spin(double delta) {
set_rotation(get_rotation() + delta);
return get_rotation();
}
void _ready() override {
UtilityFunctions::print(spin(0.5));
UtilityFunctions::print(spin(0.25));
}
};Both columns accumulate the same rotation, which is the part that translates unchanged —
rotation is a property in GDScript and a set_rotation/get_rotation pair in C++, and they are the same engine value. What does not translate is the workflow: there is no hot reload, so changing the C++ file means rebuilding the library and restarting the editor, where GDScript reloads on save. That, rather than the syntax, is what changes day to day.Making something reachable by name
Everything the engine, the editor or a GDScript file needs to reach has to be declared in
_bind_methods() by name. One GDScript annotation becomes several lines here, and this is the main ongoing cost of working at this layer.extends Node
class_name Bell
signal rung(times: int)
@export var volume: float = 0.5
func ring(times: int) -> void:
rung.emit(times)
func _ready() -> void:
rung.connect(func(count): print("rung %d" % count))
ring(3)#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/core/class_db.hpp>
using namespace godot;
class Bell : public Node {
GDCLASS(Bell, Node)
double volume = 0.5;
protected:
static void _bind_methods() {
ClassDB::bind_method(D_METHOD("ring", "times"), &Bell::ring);
ClassDB::bind_method(D_METHOD("set_volume", "value"), &Bell::set_volume);
ClassDB::bind_method(D_METHOD("get_volume"), &Bell::get_volume);
ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "volume"), "set_volume", "get_volume");
ADD_SIGNAL(MethodInfo("rung", PropertyInfo(Variant::INT, "times")));
}
public:
void set_volume(double value) { volume = value; }
double get_volume() const { return volume; }
void ring(int times) { emit_signal("rung", times); }
};Compare the two columns line for line:
signal rung(times) is ADD_SIGNAL, @export var volume is a setter, a getter and an ADD_PROPERTY, and a method callable from GDScript needs bind_method. Nothing is automatic, because the engine reads this table rather than your class.Who owns a node
A
Node is not reference counted. In GDScript that is invisible; in C++ you allocate it with memnew and then hand ownership to a parent, and the parent is what eventually destroys it.extends Node
func _ready() -> void:
var child := Node.new()
child.name = "Spawned"
add_child(child)
print(get_child_count())
print(child.get_parent() == self)#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/core/memory.hpp>
using namespace godot;
class Spawner : public Node {
GDCLASS(Spawner, Node)
protected:
static void _bind_methods() {}
public:
void _ready() override {
Node* child = memnew(Node);
child->set_name("Spawned");
add_child(child);
// No delete: the parent owns it now, and queue_free() is how it goes.
}
};The rule is worth stating plainly: a node you
add_child is no longer yours to delete, and one you never parent is yours and will leak if you drop it. Reference-counted types use Ref<T> instead, which behaves like the GDScript object handle you are used to.Drawing It, Side by Side
A stat panel, drawn live
The GDScript column below runs in a real Godot engine, and the panel under it is that engine drawing. The C++ column is the same panel as a GDExtension class — read them as one program in two spellings, because that is what they are.
extends Control
func _ready() -> void:
queue_redraw()
func _draw() -> void:
var pane := get_viewport_rect().size
var rows := [
{"label": "Strength", "value": 0.82, "tint": Color(0.91, 0.36, 0.35)},
{"label": "Agility", "value": 0.64, "tint": Color(0.39, 0.72, 0.45)},
{"label": "Focus", "value": 0.45, "tint": Color(0.37, 0.58, 0.87)},
{"label": "Luck", "value": 0.28, "tint": Color(0.85, 0.71, 0.32)},
]
var font := ThemeDB.fallback_font
var top := 34.0
var gap := (pane.y - 52.0) / rows.size()
for row in rows:
var track := Rect2(150, top, pane.x - 190, gap * 0.5)
draw_rect(track, Color(1, 1, 1, 0.12), true)
var filled := Rect2(track.position, Vector2(track.size.x * row["value"], track.size.y))
draw_rect(filled, row["tint"], true)
draw_string(font, Vector2(24, top + gap * 0.42), row["label"],
HORIZONTAL_ALIGNMENT_LEFT, -1, 17)
draw_string(font, Vector2(track.position.x + track.size.x + 8, top + gap * 0.42),
"%d%%" % int(row["value"] * 100), HORIZONTAL_ALIGNMENT_LEFT, -1, 15)
top += gap#include <godot_cpp/classes/control.hpp>
#include <godot_cpp/classes/theme_db.hpp>
#include <godot_cpp/core/class_db.hpp>
using namespace godot;
struct StatRow {
String label;
double value;
Color tint;
};
class StatPanel : public Control {
GDCLASS(StatPanel, Control)
protected:
static void _bind_methods() {}
public:
void _ready() override { queue_redraw(); }
void _draw() override {
Vector2 pane = get_viewport_rect().size;
StatRow rows[] = {
{"Strength", 0.82, Color(0.91, 0.36, 0.35)},
{"Agility", 0.64, Color(0.39, 0.72, 0.45)},
{"Focus", 0.45, Color(0.37, 0.58, 0.87)},
{"Luck", 0.28, Color(0.85, 0.71, 0.32)},
};
Ref<Font> font = ThemeDB::get_singleton()->get_fallback_font();
double top = 34.0;
double gap = (pane.y - 52.0) / 4.0;
for (const StatRow& row : rows) {
Rect2 track(150, top, pane.x - 190, gap * 0.5);
draw_rect(track, Color(1, 1, 1, 0.12), true);
draw_rect(Rect2(track.position,
Vector2(track.size.x * row.value, track.size.y)),
row.tint, true);
draw_string(font, Vector2(24, top + gap * 0.42), row.label,
HORIZONTAL_ALIGNMENT_LEFT, -1, 17);
top += gap;
}
}
};Every drawing call has the same name and the same arguments:
draw_rect and draw_string are the same engine methods. The differences are all C++'s own — a struct with named fields replaces the dictionary, Ref<Font> is the reference-counted handle where GDScript just holds the object, and ThemeDB is reached through get_singleton() rather than as a global.A ball, bouncing until it settles
Same physics, same trail, same engine calls, once per frame in
_process. It runs until the bounce dies out and then stops — a code example, not a screen saver.extends Control
var place := Vector2(80, 40)
var drift := Vector2(210, 0)
var trail: PackedVector2Array = []
var settled := false
func _process(delta: float) -> void:
var pane := get_viewport_rect().size
drift.y += 900.0 * delta
place += drift * delta
if place.y > pane.y - 14.0:
place.y = pane.y - 14.0
drift.y = -drift.y * 0.74
if absf(drift.y) < 40.0:
settled = true
if place.x > pane.x - 14.0 or place.x < 14.0:
drift.x = -drift.x
trail.append(place)
if trail.size() > 220:
trail.remove_at(0)
if settled:
set_process(false) # for an endless demo, reset place and drift here
queue_redraw()
func _draw() -> void:
if trail.size() > 1:
draw_polyline(trail, Color(0.45, 0.68, 0.95, 0.5), 2.0, true)
draw_circle(place, 13.0, Color(0.96, 0.78, 0.31))#include <godot_cpp/classes/control.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
using namespace godot;
class Bouncer : public Control {
GDCLASS(Bouncer, Control)
Vector2 place = Vector2(80, 40);
Vector2 drift = Vector2(210, 0);
PackedVector2Array trail;
bool settled = false;
protected:
static void _bind_methods() {}
public:
void _process(double delta) override {
Vector2 pane = get_viewport_rect().size;
drift.y += 900.0 * delta;
place += drift * delta;
if (place.y > pane.y - 14.0) {
place.y = pane.y - 14.0;
drift.y = -drift.y * 0.74;
if (Math::abs(drift.y) < 40.0) {
settled = true;
}
}
if (place.x > pane.x - 14.0 || place.x < 14.0) {
drift.x = -drift.x;
}
trail.push_back(place);
if (trail.size() > 220) {
trail.remove_at(0);
}
if (settled) {
set_process(false);
}
queue_redraw();
}
void _draw() override {
if (trail.size() > 1) {
draw_polyline(trail, Color(0.45, 0.68, 0.95, 0.5), 2.0, true);
}
draw_circle(place, 13.0, Color(0.96, 0.78, 0.31));
}
};This row is also the clearest case for NOT reaching for C++. The per-frame work is one vector add, two comparisons and a
draw_polyline; the C++ version buys nothing and costs a build step. Reach for GDExtension where the work is per element, per frame — a particle solver, a mesh build, a pathfinder over thousands of cells — because that is where the cost of crossing into script per item is what you are removing.