PONYλM2Modula-2

GDScript.CodeCompared.To/C#

An interactive executable cheatsheet comparing GDScript and C#

GDScript 4.7 (Godot 4.7.2) C# 14 (.NET 10)
Hello World & Output
Hello, World
Both columns on this page run for real: the GDScript on the left executes in an actual Godot engine, and the C# on the right compiles and runs as a .NET program. Where a row needs the engine itself — a node, a signal the editor can see — the C# side is shown rather than run, and says so.
print("Hello, World!")
Console.WriteLine("Hello, World!");
C# has no global print. Output goes through Console.WriteLine, and the semicolon is not optional — statements are terminated rather than newline-delimited.
Putting a value in a message
GDScript formats with the % operator and an array of values, in the order the placeholders appear. C# puts the expression itself inside the string, so there is no second list to keep in sync.
var player_name := "Robi" var score := 1200 print("%s scored %d" % [player_name, score])
var playerName = "Robi"; var score = 1200; Console.WriteLine($"{playerName} scored {score}");
The $ prefix is what makes a string interpolated. Because the expression is written where it appears, adding a value never means renumbering anything, and the compiler checks each one.
Everything is renamed
This is the first thing to get used to, because it applies to every line you write afterwards. GDScript is snake_case throughout; C# in Godot is PascalCase for methods and public members, camelCase for locals and parameters.
var health_points := 100 func take_damage(amount: int) -> void: health_points -= amount take_damage(30) print(health_points)
int healthPoints = 100; void TakeDamage(int amount) { healthPoints -= amount; } TakeDamage(30); Console.WriteLine(healthPoints);
The rename reaches the engine too — _ready is _Ready, queue_free is QueueFree, get_tree is GetTree. Godot renames its whole API for the C# bindings, so a method you know translates mechanically rather than needing to be looked up.
Braces instead of indentation
GDScript decides what is inside the if from the indentation, and the condition needs no parentheses. C# decides from the braces, and the condition requires them.
var temperature := 35 if temperature > 30: print("hot") print("drink water") print("done")
var temperature = 35; if (temperature > 30) { Console.WriteLine("hot"); Console.WriteLine("drink water"); } Console.WriteLine("done");
Indentation in C# is only for people — the compiler ignores it entirely. That means a misleadingly indented line is a readability bug rather than a behavior change, which is the opposite of the risk you are used to.
Variables & Types
There is no untyped option
A bare var in GDScript is a Variant: it holds anything and can be reassigned to a different kind of thing later. C# has no such default. The nearest equivalent is object, and you have to ask for it by name.
var anything = 42 anything = "now a string" anything = [1, 2, 3] print(anything)
object anything = 42; anything = "now a string"; anything = new List<int> { 1, 2, 3 }; Console.WriteLine(anything);
Reaching for object costs you everything the type system was going to do for you — you cannot call a string method on it without casting first. In practice C# code almost never uses it, which is the real difference: the escape hatch exists and is not the default.
`var` means inference, not dynamic
C# spells inference var, which is the same word GDScript uses for the dynamic case. The behavior is GDScript's :=, not GDScript's var.
var speed := 200.0 var label := "boss" print(speed) print(label)
var speed = 200.0; var label = "boss"; Console.WriteLine(speed); Console.WriteLine(label);
The type is fixed at the declaration and cannot change afterwards, exactly as with :=. Assigning a string to speed on the next line is a compile error, not a runtime surprise.
A condition must be a bool
GDScript treats 0, "", an empty array and null as false. C# has no truthiness at all: a condition must already be a bool, so you say what you actually meant.
var health := 0 var name := "" if not health: print("no health") if not name: print("no name")
int health = 0; string name = ""; if (health == 0) { Console.WriteLine("no health"); } if (name.Length == 0) { Console.WriteLine("no name"); }
This is the change most likely to catch you, and it fails loudly rather than quietly — if (health) does not compile. What you lose in brevity you gain in never having to remember which values a language considers empty.
Integer division stays integer
Both languages truncate when both operands are integers, and both promote as soon as one side is floating point. The rule is the same; only the cast syntax differs.
print(7 / 2) print(7.0 / 2) print(float(7) / 2)
Console.WriteLine(7 / 2); Console.WriteLine(7.0 / 2); Console.WriteLine((float)7 / 2);
GDScript writes the conversion as a call, float(x); C# writes it as a prefix cast, (float)x. C# also warns you far more often, because a conversion that could lose data has to be written down.
Constants and enums
Both have real constants and real enums. The visible difference is that a GDScript enum value is its integer, while a C# enum is a distinct type that knows its own name.
const MAX_HEALTH := 100 enum State { IDLE, WALKING, JUMPING } var current := State.WALKING print(MAX_HEALTH) print(current) print(State.keys()[current])
const int MaxHealth = 100; var current = State.Walking; Console.WriteLine(MaxHealth); Console.WriteLine((int)current); Console.WriteLine(current); enum State { Idle, Walking, Jumping }
Printing a C# enum gives you Walking, not 1 — the name survives to runtime, so the State.keys()[current] lookup has no equivalent because it is not needed. Note the enum is declared after the statements: top-level statements come first in this runner.
Null has to be asked for
Any GDScript variable can hold null. In C# a reference type is non-nullable unless you mark it with ?, and the compiler warns when you use one that might be null without checking.
var target = null print(target == null) target = "found" print(target)
string? target = null; Console.WriteLine(target == null); target = "found"; Console.WriteLine(target);
The ? is the whole feature: it moves "can this be null?" out of your head and into the declaration, so the compiler can tell you about the case you forgot rather than the game finding it during a playtest.
Strings
Length, case, and slicing
The operations line up almost one for one. The one thing to notice is that C# exposes length as a property rather than a method — no parentheses.
var title := "Crystal Cavern" print(title.length()) print(title.to_upper()) print(title.substr(0, 7)) print(title.contains("Cave"))
var title = "Crystal Cavern"; Console.WriteLine(title.Length); Console.WriteLine(title.ToUpper()); Console.WriteLine(title.Substring(0, 7)); Console.WriteLine(title.Contains("Cave"));
A property is accessed like a field and computed like a method. You will meet the same pattern on Count for collections, which is why Length() is a compile error rather than a slower spelling.
Splitting and joining
Splitting is the same call. Joining reads backwards from GDScript: the separator is an argument to a static string.Join rather than the string you call the method on.
var csv := "sword,shield,potion" var items := csv.split(",") print(items) print(" + ".join(items))
var csv = "sword,shield,potion"; var items = csv.Split(","); Console.WriteLine(string.Join(", ", items)); Console.WriteLine(string.Join(" + ", items));
Printing an array directly also differs — C# would show the type name rather than the contents, so this example joins it instead. That is a general habit worth forming early: collections do not print themselves in C#.
Building a string in a loop
Both languages have immutable strings, so += in a loop allocates a fresh one every pass. GDScript wears that cost quietly; C# gives you a builder to avoid it.
var report := "" for level in range(1, 4): report += "level %d cleared\n" % level print(report.strip_edges())
var report = new StringBuilder(); for (int level = 1; level < 4; level++) { report.AppendLine($"level {level} cleared"); } Console.WriteLine(report.ToString().TrimEnd());
StringBuilder keeps one growable buffer and produces the finished string once. For a handful of items it does not matter, but this is the idiom C# code reaches for by default, so it is worth recognizing on sight.
Parsing a number out of text
GDScript's to_int() never fails — unparseable text quietly becomes 0, which is why is_valid_int() exists as a separate check. C# makes you pick: Parse throws, or TryParse reports.
var typed := "42" var bad := "twelve" print(typed.to_int()) print(bad.to_int()) print(typed.is_valid_int()) print(bad.is_valid_int())
var typed = "42"; var bad = "twelve"; Console.WriteLine(int.Parse(typed)); Console.WriteLine(int.TryParse(bad, out int parsed)); Console.WriteLine(parsed); Console.WriteLine(int.TryParse(typed, out int good) ? good : 0);
TryParse returns whether it worked and writes the value through out, so one call does the checking and the converting together. The failed parse leaves parsed at 0 — the same value GDScript would have given you, but this time you were told.
Numbers & Math
The math you use every frame
GDScript puts these in the global scope, so you call them bare. C# groups them on the Math class, so every call is prefixed.
print(abs(-7)) print(round(2.6)) print(min(3, 9)) print(clamp(15, 0, 10)) print(sqrt(16.0))
Console.WriteLine(Math.Abs(-7)); Console.WriteLine(Math.Round(2.6)); Console.WriteLine(Math.Min(3, 9)); Console.WriteLine(Math.Clamp(15, 0, 10)); Console.WriteLine(Math.Sqrt(16.0));
Godot's C# bindings also ship Mathf, which offers the float-first, game-flavored versions of these — Mathf.Lerp, Mathf.DegToRad — matching the names you already know from GDScript.
float and double are different types
Every GDScript float is a 64-bit double. C# has both float (32-bit) and double (64-bit), and a bare decimal literal is a double — the f suffix is what makes it a float.
var speed := 0.1 var total := speed + 0.2 print(total) print(is_equal_approx(total, 0.3))
float speed = 0.1f; float total = speed + 0.2f; Console.WriteLine(total); Console.WriteLine(Math.Abs(total - 0.3f) < 0.0001f);
Godot's C# API uses 32-bit float for positions and vectors, so the f suffix becomes reflex quickly. Leaving it off is one of the most common first-week compile errors, and it is caught at build time rather than in the game.
Integers have edges now
GDScript has one integer type, always 64-bit. C# has several, and the default int is 32-bit — which is smaller than what you have been using without thinking about it.
var big := 9223372036854775807 print(big) print(typeof(big) == TYPE_INT)
int small = int.MaxValue; long big = long.MaxValue; Console.WriteLine(small); Console.WriteLine(big); Console.WriteLine(small + 1L);
A counter that could exceed about two billion needs long, and the 1L suffix forces the arithmetic to happen in 64 bits rather than overflowing first and widening afterwards. Godot's own integer properties are int, so this mostly matters for your own tallies.
Random numbers
GDScript exposes one global generator seeded with seed(). C# makes the generator an object you create, which means separate streams can be seeded independently.
seed(12345) print(randi() % 6 + 1) print(randi() % 6 + 1) print(randf() < 2.0)
var random = new Random(12345); Console.WriteLine(random.Next(1, 7)); Console.WriteLine(random.Next(1, 7)); Console.WriteLine(random.NextDouble() < 2.0);
Next(1, 7) gives the inclusive-exclusive range directly, so the % 6 + 1 arithmetic disappears — along with the modulo bias it quietly carries. Godot's C# side also offers GD.Randi() if you want the engine's generator instead.
Arrays & Lists
The everyday array
GDScript's Array becomes List<T> — growable like the one you know, but every element is the same declared type. The angle brackets are the element type.
var loot := ["sword", "shield", "potion"] loot.append("rope") print(loot.size()) print(loot[0]) print(loot[-1]) print(loot.has("shield"))
var loot = new List<string> { "sword", "shield", "potion" }; loot.Add("rope"); Console.WriteLine(loot.Count); Console.WriteLine(loot[0]); Console.WriteLine(loot[^1]); Console.WriteLine(loot.Contains("shield"));
Negative indexing exists but is spelled differently: loot[^1] is the last element, where ^ means "from the end". Plain loot[-1] throws, because -1 is just an out-of-range index.
Typed arrays stop being optional
GDScript added Array[int] as an option and left plain Array as the default. In C# the element type is part of the type, so the mixed array needs an explicit List<object> to be legal at all.
var scores: Array[int] = [10, 20, 30] scores.append(40) print(scores) var mixed := [1, "two", 3.0] print(mixed)
var scores = new List<int> { 10, 20, 30 }; scores.Add(40); Console.WriteLine(string.Join(", ", scores)); var mixed = new List<object> { 1, "two", 3.0 }; Console.WriteLine(string.Join(", ", mixed));
Writing List<object> is deliberately awkward, and that is the point — a heterogeneous collection is usually a design you would rather notice than inherit.
A fixed-size array is a separate thing
C# distinguishes a fixed-size array (string[], allocated once, cannot grow) from a growable List<T>. GDScript has only the growable kind, with resize to pre-size it.
var grid := [] grid.resize(3) grid[0] = "x" print(grid) print(grid.size())
var grid = new string[3]; grid[0] = "x"; Console.WriteLine(string.Join(", ", grid.Select(cell => cell ?? "null"))); Console.WriteLine(grid.Length);
Note it is Length for an array and Count for a list — the two collections do not share a spelling. The unset slots are null rather than GDScript's <null> placeholder, which is why they are substituted here to print.
Sorting by something
GDScript sorts with a comparator returning true when the first argument comes first. C# expects a three-way comparison — negative, zero or positive — which CompareTo produces.
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"]])
var enemies = new List<(string Name, int Hp)> { ("bat", 12), ("golem", 90), ("slime", 30), }; enemies.Sort((a, b) => a.Hp.CompareTo(b.Hp)); foreach (var enemy in enemies) { Console.WriteLine($"{enemy.Name} {enemy.Hp}"); }
The C# side also swaps the dictionary for a tuple with named fields, so enemy.Hp is checked at compile time where enemy["hp"] could only fail when the frame ran. That substitution is the single most common shape change when porting GDScript data.
Dictionaries & Sets
The everyday dictionary
Both the key type and the value type are declared up front in C#. A GDScript dictionary can mix 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())
var stats = new Dictionary<string, int> { ["strength"] = 12, ["agility"] = 8, }; stats["luck"] = 3; Console.WriteLine(stats["strength"]); Console.WriteLine(stats.ContainsKey("agility")); Console.WriteLine(stats.Count);
A missing key throws KeyNotFoundException rather than returning null, so the failure surfaces where the typo is rather than several lines later when something unexpectedly holds nothing.
Reading a key that might not be there
GDScript's get with a fallback is GetValueOrDefault. The difference is what happens when you omit the fallback: GDScript hands back null, while C# has no null to hand back for a number.
var config := {"volume": 0.8} print(config.get("volume", 1.0)) print(config.get("brightness", 1.0)) print(config.get("brightness"))
var config = new Dictionary<string, double> { ["volume"] = 0.8 }; Console.WriteLine(config.GetValueOrDefault("volume", 1.0)); Console.WriteLine(config.GetValueOrDefault("brightness", 1.0)); Console.WriteLine(config.TryGetValue("brightness", out double found) ? found : 0.0);
That is why TryGetValue exists — it separates "was it there" from "what was it", so a legitimately stored zero cannot be confused with a missing key. In GDScript those two cases look identical.
Walking a dictionary
Iterating a GDScript dictionary gives you keys, and you look the value up yourself. C# gives you both halves at once, so the second lookup disappears.
var loadout := {"head": "helm", "hand": "mace"} for slot in loadout: print("%s -> %s" % [slot, loadout[slot]])
var loadout = new Dictionary<string, string> { ["head"] = "helm", ["hand"] = "mace", }; foreach (var (slot, item) in loadout) { Console.WriteLine($"{slot} -> {item}"); }
The var (slot, item) form destructures each entry in place. Neither language promises an order here, so a sorted display still needs an explicit sort.
A set, which you did not have
GDScript has no set type, so the idiom is a dictionary with throwaway values. C# has HashSet<T>, where Add on a duplicate simply returns false and changes nothing.
var seen := {} for tag in ["fire", "ice", "fire", "wind"]: seen[tag] = true var unique := seen.keys() unique.sort() print(unique) print(unique.size())
var seen = new HashSet<string>(); foreach (var tag in new[] { "fire", "ice", "fire", "wind" }) { seen.Add(tag); } var unique = seen.OrderBy(tag => tag).ToList(); Console.WriteLine(string.Join(", ", unique)); Console.WriteLine(unique.Count);
It also brings the set operations with it — UnionWith, IntersectWith, ExceptWith — which are the reason to use one rather than the deduplication alone.
Control Flow
if / elif / else
The only structural change is that elif is spelled 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")
var health = 45; if (health > 70) { Console.WriteLine("healthy"); } else if (health > 25) { Console.WriteLine("hurt"); } else { Console.WriteLine("critical"); }
Braces around a single statement are optional in C# but conventional, and Godot's own C# samples always include them. Omitting them is how the famous "second line looks like it is inside the if but is not" bug happens.
match becomes switch
GDScript's match ends each branch automatically and writes multiple patterns on one line separated by commas. C# needs an explicit break and stacks the labels instead.
var state := "walking" match state: "idle": print("standing still") "walking", "running": print("moving") _: print("unknown")
var state = "walking"; switch (state) { case "idle": Console.WriteLine("standing still"); break; case "walking": case "running": Console.WriteLine("moving"); break; default: Console.WriteLine("unknown"); break; }
Forgetting break is a compile error rather than the silent fall-through C inherited, so the classic bug cannot happen. The _ catch-all is spelled default.
The switch that returns a value
C# has a second form that is an expression: it evaluates to a value instead of running statements, so there is no return in each arm and no way to fall out without producing something.
func describe(state: String) -> String: match state: "idle": return "standing still" "walking", "running": return "moving" _: return "unknown" print(describe("running")) print(describe("flying"))
string Describe(string state) => state switch { "idle" => "standing still", "walking" or "running" => "moving", _ => "unknown", }; Console.WriteLine(Describe("running")); Console.WriteLine(Describe("flying"));
The compiler checks that every input is covered, so dropping the _ arm produces a warning about the case you did not handle. GDScript's match has no such check — a missing branch is simply a function that returns null.
Matching on shape, not just value
GDScript can match on typeof, but the value stays a Variant and you keep using it as one. C# tests the type and binds a typed name in the same step, so number is an int inside that branch.
var reading = 42 match typeof(reading): TYPE_INT: print("an int: %d" % reading) TYPE_STRING: print("a string") _: print("something else")
object reading = 42; switch (reading) { case int number when number > 40: Console.WriteLine($"a big int: {number}"); break; case int number: Console.WriteLine($"an int: {number}"); break; case string text: Console.WriteLine($"a string of {text.Length}"); break; default: Console.WriteLine("something else"); break; }
The when clause adds a condition to a pattern, which is how the two int cases can differ. This is the feature with no GDScript equivalent at all, and it is what makes C# comfortable handling data whose shape is not known up front.
Loops & Iteration
Walking a collection
GDScript's for x in collection is C#'s foreach. The keyword changes because C# reserves plain for for the counted form.
var party := ["Ari", "Bex", "Cyd"] for member in party: print(member)
var party = new List<string> { "Ari", "Bex", "Cyd" }; foreach (var member in party) { Console.WriteLine(member); }
The loop variable is read-only inside the body, so a stray assignment to member is a compile error rather than a change that silently affects nothing.
Counting
range() has no C# counterpart in this position. The counted for spells out the same three things — where to start, when to stop, how to step — as separate clauses.
for i in range(3): print(i) for i in range(2, 8, 2): print(i)
for (int i = 0; i < 3; i++) { Console.WriteLine(i); } for (int i = 2; i < 8; i += 2) { Console.WriteLine(i); }
The stop condition is written as the comparison rather than implied, which makes an inclusive range a matter of changing < to <= rather than remembering that range excludes its end.
Needing the index too
The GDScript habit is to loop over indices and index back into the array. C# can pair each element with its position instead, so there is no indexing at all.
var waves := ["bats", "slimes", "boss"] for i in range(waves.size()): print("wave %d: %s" % [i + 1, waves[i]])
var waves = new List<string> { "bats", "slimes", "boss" }; foreach (var (wave, index) in waves.Select((wave, index) => (wave, index))) { Console.WriteLine($"wave {index + 1}: {wave}"); }
The Select overload that takes an index is doing the work here. It reads as a mouthful the first time, and it removes the class of bug where the index and the collection drift apart.
while, break and continue
These carry over unchanged apart from the parentheses and braces. Both languages spell the two escapes the same way.
var charges := 5 while charges > 0: charges -= 1 if charges == 3: continue if charges == 1: break print(charges)
var charges = 5; while (charges > 0) { charges -= 1; if (charges == 3) { continue; } if (charges == 1) { break; } Console.WriteLine(charges); }
C# adds do { ... } while (condition);, which runs the body once before testing. GDScript has no such form, so the pattern there is a while true with the test at the bottom.
Functions & Lambdas
Declaring a function
GDScript writes the return type after an arrow; C# writes it before the name, where the func keyword used to be. Parameter types are written the same way round in both.
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))
int DamageAfterArmor(int damage, int armor) { return Math.Max(damage - armor, 0); } Console.WriteLine(DamageAfterArmor(30, 12)); Console.WriteLine(DamageAfterArmor(5, 12));
The annotations are optional in GDScript and mandatory in C#. That is the trade in one line: you can no longer sketch a function and fill the types in later, and in exchange nothing can call it with the wrong ones.
Default and named arguments
Defaults work identically. C# adds something GDScript has no form for: naming an argument at the call site, which lets you skip past a default or reorder for readability.
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))
string Spawn(string kind, int count = 1, bool elite = false) { return $"{count} {kind}{(elite ? " (elite)" : "")}"; } Console.WriteLine(Spawn("bat")); Console.WriteLine(Spawn("golem", 3)); Console.WriteLine(Spawn("slime", elite: true, count: 2));
The last call passes elite before count and still binds each to the right parameter. It also documents itself — Spawn("slime", 2, true) needs the declaration to interpret, and the named version does not.
Lambdas and Callables
A GDScript lambda produces a Callable, which you invoke with .call(). A C# lambda produces a delegate you invoke like any other function — the parentheses are enough.
var double_it := func(value: int) -> int: return value * 2 print(double_it.call(7)) var numbers := [1, 2, 3, 4] var doubled := numbers.map(func(value): return value * 2) print(doubled)
var doubleIt = (int value) => value * 2; Console.WriteLine(doubleIt(7)); var numbers = new List<int> { 1, 2, 3, 4 }; var doubled = numbers.Select(value => value * 2); Console.WriteLine(string.Join(", ", doubled));
Because the delegate carries its parameter and return types, passing a two-argument lambda where one is expected is a compile error. In GDScript that same mistake reaches you as a runtime error on the frame the callback fires.
Returning more than one value
The GDScript habit is to return a dictionary and read it back by string key. C# returns a tuple whose parts have names and types, so the reading side is checked.
func split_damage(total: int) -> Dictionary: return {"physical": total / 2, "fire": total - total / 2} var parts := split_damage(9) print(parts["physical"]) print(parts["fire"])
(int Physical, int Fire) SplitDamage(int total) { return (total / 2, total - total / 2); } var parts = SplitDamage(9); Console.WriteLine(parts.Physical); Console.WriteLine(parts.Fire);
Misspelling parts.Fyre does not compile, where parts["fyre"] would hand back null and fail somewhere else entirely. The tuple also costs no allocation, which the dictionary does.
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 itself. C# picks between same-named methods by the argument types at the call site.
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"))
Console.WriteLine(Describer.Describe(7)); Console.WriteLine(Describer.Describe("seven")); static class Describer { public static string Describe(int value) => $"number {value}"; public static string Describe(string value) => $"text {value}"; }
The caller writes Describe either way and the compiler chooses by argument type. Overloading belongs to members of a type, which is why the two live in a class here — plain nested functions cannot share a name any more than GDScript's can. This is how Godot's C# API offers several shapes of one call, GetNode(path) and GetNode<T>(path), where GDScript would need two names.
Classes & Objects
Declaring a class
A GDScript file is a class, named with class_name. In C# the class is written out explicitly and there can be several in a file. The constructor is a method named after the class rather than _init.
class_name Potion extends RefCounted var strength: int func _init(strength_value: int) -> void: strength = strength_value func describe() -> String: return "potion of %d" % strength var small := Potion.new(5) print(small.describe())
var small = new Potion(5); Console.WriteLine(small.Describe()); class Potion { public int Strength; public Potion(int strength) { Strength = strength; } public string Describe() => $"potion of {Strength}"; }
Note public: C# members are private unless you say otherwise, where every GDScript member is reachable from anywhere. The class appears after the statements here because top-level statements must come first in this runner.
A property that computes
Both languages let a name look like a field and behave like a method. GDScript spells it with a get: block; C# uses an arrow for the read-only case.
class Health: var current: int = 30 var maximum: int = 100 var fraction: float: get: return float(current) / maximum var bar := Health.new() print(bar.fraction)
var bar = new Health(); Console.WriteLine(bar.Fraction); class Health { public int Current = 30; public int Maximum = 100; public float Fraction => (float)Current / Maximum; }
A read-only property is the C# default and takes one line. Adding { get; set; } makes it writable, and a full set body is where you would put the clamping that a health bar usually wants.
Inheritance and overriding
GDScript overrides silently — redefining a method in a subclass replaces it. C# requires the base to opt in with virtual and the child to opt in with override.
class Enemy: func speak() -> String: return "..." class Bat extends Enemy: func speak() -> String: return "screech" var creature: Enemy = Bat.new() print(creature.speak())
Enemy creature = new Bat(); Console.WriteLine(creature.Speak()); class Enemy { public virtual string Speak() => "..."; } class Bat : Enemy { public override string Speak() => "screech"; }
Both keywords are mandatory, which turns two silent GDScript bugs into compile errors: overriding a method the parent never meant to expose, and misspelling the name so you accidentally add a new method instead of replacing one.
A value object in one line
A record declares the fields, the constructor, value equality and a readable ToString in a single line. GDScript has no equivalent, which is why the anchor column writes each piece by hand.
class_name Point extends RefCounted var x: int var y: int func _init(x_value: int, y_value: int) -> void: x = x_value y = y_value func equals(other: Point) -> bool: return x == other.x and y == other.y var first := Point.new(2, 3) var second := Point.new(2, 3) print(first.equals(second)) print(first == second)
var first = new Point(2, 3); var second = new Point(2, 3); Console.WriteLine(first == second); Console.WriteLine(first); record Point(int X, int Y);
The payoff is on the last two GDScript lines: first == second is false there, because two objects with identical contents are still two objects. A record compares by value, so the same expression is true and no hand-written equals is needed.
Interfaces, which duck typing replaced
GDScript calls open() on anything that happens to have it, and finds out at runtime whether it did. C# asks the two classes to declare a shared interface, and then the compiler knows.
class Chest: func open() -> String: return "gold" class Door: func open() -> String: return "a corridor" for thing in [Chest.new(), Door.new()]: print(thing.open())
foreach (IOpenable thing in new IOpenable[] { new Chest(), new Door() }) { Console.WriteLine(thing.Open()); } interface IOpenable { string Open(); } class Chest : IOpenable { public string Open() => "gold"; } class Door : IOpenable { public string Open() => "a corridor"; }
The interface is extra typing that buys a guarantee: adding a third class to that array without an Open method is a compile error rather than a crash the first time a player walks into the room containing it.
Types You Now Have to Mean
Writing something that works for any type
GDScript writes this by giving up on types — Array and an unannotated fallback. C# introduces a type parameter T, so the function stays general and stays checked.
func first_or_default(items: Array, fallback): return items[0] if items.size() > 0 else fallback print(first_or_default([10, 20], 0)) print(first_or_default([], 0)) print(first_or_default(["a"], "z"))
T FirstOrFallback<T>(List<T> items, T fallback) { return items.Count > 0 ? items[0] : fallback; } Console.WriteLine(FirstOrFallback(new List<int> { 10, 20 }, 0)); Console.WriteLine(FirstOrFallback(new List<int>(), 0)); Console.WriteLine(FirstOrFallback(new List<string> { "a" }, "z"));
Every call above infers T from the arguments, so the generality costs nothing at the call site. Mixing them — a List<int> with a string fallback — does not compile, which the GDScript version would accept and then return the wrong kind of thing.
struct copies, class shares
You already meet this in GDScript without a name for it: Vector2 copies on assignment, an Array does not. C# gives the rule a name — struct copies, class shares — and lets you choose it for your own types.
var first := Vector2(1, 2) var second := first second.x = 99 print(first) print(second) var list_first := [1, 2] var list_second := list_first list_second.append(3) print(list_first)
var first = new Coordinate { X = 1, Y = 2 }; var second = first; second.X = 99; Console.WriteLine($"({first.X}, {first.Y})"); Console.WriteLine($"({second.X}, {second.Y})"); var listFirst = new List<int> { 1, 2 }; var listSecond = listFirst; listSecond.Add(3); Console.WriteLine(string.Join(", ", listFirst)); struct Coordinate { public int X; public int Y; }
This is the distinction that decides most surprises when porting. A struct passed to a method is a copy, so mutating it inside changes nothing outside — which is exactly why Godot makes Vector2 one.
Casting, and asking first
Both have is. The C# version does more in the same breath: it tests the type and gives you a correctly typed name for the value, so the following line needs no cast.
var thing = "a string" if thing is String: print("string of %d" % thing.length()) if thing is int: print("never printed")
object thing = "a string"; if (thing is string text) { Console.WriteLine($"string of {text.Length}"); } var maybeNumber = thing as string; Console.WriteLine(maybeNumber ?? "was not a string");
as is the other half — it converts, or yields null rather than throwing, which pairs with ?? to supply a fallback. A hard cast, (string)thing, throws instead, and that is the one to use when being wrong is a bug rather than a case.
Errors You Can Finally Raise
GDScript cannot raise; C# can
This is the largest single gain on this page. 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))
int Withdraw(int balance, int amount) { if (amount > balance) { throw new InvalidOperationException("insufficient funds"); } return balance - amount; } Console.WriteLine(Withdraw(100, 30)); try { Console.WriteLine(Withdraw(100, 500)); } catch (InvalidOperationException error) { Console.WriteLine($"caught: {error.Message}"); }
Look at what the GDScript column has to do — return the unchanged balance, which is indistinguishable from a withdrawal of zero. C# stops the function, and an unhandled throw is loud rather than silent. The cost is that you must now decide where to catch.
Cleanup that always happens
Without exceptions, every early exit in GDScript has to repeat its own cleanup — note close appears twice in the anchor column, and a third exit would need a third copy.
var log := [] func risky(should_fail: bool, log_target: Array) -> void: log_target.append("open") if should_fail: log_target.append("close") return log_target.append("work") log_target.append("close") risky(false, log) risky(true, log) print(log)
var log = new List<string>(); void Risky(bool shouldFail) { log.Add("open"); try { if (shouldFail) { throw new Exception("failed"); } log.Add("work"); } finally { log.Add("close"); } } Risky(false); try { Risky(true); } catch { } Console.WriteLine(string.Join(", ", log));
finally runs whether the block completed, returned, or threw, so the cleanup is written once. This is the pattern behind using, which is how C# closes files and streams without anyone remembering to.
An error type of your own
The GDScript idiom is a result dictionary carrying a success flag, which every caller has to remember to check. C# lets the failure have a type of its own, which callers can catch selectively.
func parse_level(text: String) -> Dictionary: if not text.is_valid_int(): return {"ok": false, "reason": "not a number"} return {"ok": true, "value": text.to_int()} var good := parse_level("7") var bad := parse_level("seven") print(good) print(bad)
int ParseLevel(string text) { if (!int.TryParse(text, out int value)) { throw new LevelFormatException(text); } return value; } Console.WriteLine(ParseLevel("7")); try { ParseLevel("seven"); } catch (LevelFormatException error) { Console.WriteLine(error.Message); } class LevelFormatException : Exception { public LevelFormatException(string text) : base($"'{text}' is not a level number") { } }
Catching LevelFormatException specifically means an unrelated failure is not swallowed by the same handler — the distinction the {"ok": false} dictionary cannot make, because every failure has the same shape.
Querying Data with LINQ
Filtering and transforming
GDScript does have filter and map, but the accumulate-into-a-fresh-array loop is what most Godot code actually contains. LINQ is the version that chains, so the intermediate arrays never exist.
var scores := [42, 91, 7, 68, 15] var high := [] for score in scores: if score > 40: high.append(score * 10) print(high)
var scores = new List<int> { 42, 91, 7, 68, 15 }; var high = scores.Where(score => score > 40) .Select(score => score * 10); Console.WriteLine(string.Join(", ", high));
Each step names what it does rather than how — Where keeps, Select transforms. Nothing runs until the result is consumed, so adding a .Take(2) on the end would stop the whole chain after two matches rather than filtering the entire list first.
Totals, counts and extremes
Three loops collapse into three method calls. This is the clearest single argument for LINQ: the loop was never the interesting part.
var damage := [12, 40, 7, 33] var total := 0 var biggest: int = damage[0] for value in damage: total += value if value > biggest: biggest = value print(total) print(biggest) print(float(total) / damage.size())
var damage = new List<int> { 12, 40, 7, 33 }; Console.WriteLine(damage.Sum()); Console.WriteLine(damage.Max()); Console.WriteLine(damage.Average());
Average returns a double without being asked, so the integer-division trap in the GDScript column simply does not arise. Min, Count and Any round out the set you will reach for daily.
Grouping
Grouping by hand needs the "is there a bucket yet" check on every item. GroupBy makes the buckets for you and hands back each key with its members.
var units := [ {"name": "bat", "kind": "flying"}, {"name": "golem", "kind": "ground"}, {"name": "wasp", "kind": "flying"}, ] var by_kind := {} for unit in units: if not by_kind.has(unit["kind"]): by_kind[unit["kind"]] = [] by_kind[unit["kind"]].append(unit["name"]) var kinds := by_kind.keys() kinds.sort() for kind in kinds: print("%s: %s" % [kind, ", ".join(by_kind[kind])])
var units = new List<(string Name, string Kind)> { ("bat", "flying"), ("golem", "ground"), ("wasp", "flying"), }; foreach (var group in units.GroupBy(unit => unit.Kind).OrderBy(group => group.Key)) { Console.WriteLine($"{group.Key}: {string.Join(", ", group.Select(unit => unit.Name))}"); }
The chain reads as the sentence you would say out loud: group by kind, order by key, then for each group list the names. Every step is checked, so a typo in unit.Kind stops the build rather than silently creating a bucket nobody looks in.
Finding one thing
The find-then-break loop is one of the most repeated shapes in GDScript. FirstOrDefault is that loop, and Any is the version that only wants a yes or no.
var pickups := ["coin", "key", "gem"] var found = null for pickup in pickups: if pickup.begins_with("k"): found = pickup break print(found) var missing = null for pickup in pickups: if pickup.begins_with("z"): missing = pickup break print(missing)
var pickups = new List<string> { "coin", "key", "gem" }; Console.WriteLine(pickups.FirstOrDefault(pickup => pickup.StartsWith("k"))); Console.WriteLine(pickups.FirstOrDefault(pickup => pickup.StartsWith("z")) ?? "nothing"); Console.WriteLine(pickups.Any(pickup => pickup.StartsWith("g")));
It stops at the first match exactly as the break did — the whole list is not scanned. First without OrDefault throws when nothing matches, which is the right choice when finding nothing is a bug rather than an answer.
Coroutines & async
await is the same word
Both languages spell suspension await, and in both the function keeps its local state across the pause. What differs is what you are allowed to wait on.
class Countdown: signal finished(label: String) func run(label: String) -> void: finished.emit(label) var timer := Countdown.new() timer.finished.connect(func(label): print("done: %s" % label)) timer.run("wave 1") print("after")
async Task Countdown(string label) { await Task.Delay(10); Console.WriteLine($"done: {label}"); } await Countdown("wave 1"); Console.WriteLine("after");
GDScript awaits a signalawait get_tree().create_timer(1.0).timeout is the one every Godot developer writes first. C# awaits a Task, and inside Godot you bridge the two with await ToSignal(node, "signal_name"), which turns any signal into something awaitable.
A waiting function says so in its signature
A GDScript function that awaits looks identical to one that does not — the caller cannot tell from the signature, and forgetting to await it yields a coroutine object rather than the value.
func fetch_score() -> int: return 1200 print(fetch_score())
async Task<int> FetchScore() { await Task.Delay(5); return 1200; } Console.WriteLine(await FetchScore());
C# puts it in the return type: Task<int> means "an int, later". Forgetting the await gives you the task instead of the number, and the compiler warns about it — the same mistake, caught before it ships.
Waiting for several things at once
GDScript has no way to wait on several coroutines together — you await them one after another, so three one-second waits take three seconds. Task.WhenAll starts all of them and waits once.
func load_asset(name: String) -> String: return "%s loaded" % name for asset in ["terrain", "music", "atlas"]: print(load_asset(asset))
async Task<string> LoadAsset(string name) { await Task.Delay(5); return $"{name} loaded"; } var results = await Task.WhenAll( LoadAsset("terrain"), LoadAsset("music"), LoadAsset("atlas")); foreach (var line in results) { Console.WriteLine(line); }
This matters for exactly the case a loading screen has: three independent asset loads that need not be sequential. The GDScript column runs them in order because that is the only shape available.
Inside the Engine
The callbacks keep their meaning
The engine calls the same callbacks at the same moments; only the spelling changes. _ready becomes _Ready, and it is an override because the base Node declares it.
extends Node2D func _ready() -> void: print("ready") func _process(delta: float) -> void: position.x += 200.0 * delta
using Godot; public partial class Player : Node2D { public override void _Ready() { GD.Print("ready"); } public override void _Process(double delta) { Position += new Vector2(200.0f * (float)delta, 0); } }
Two things a Godot C# file always has: partial, because the build generates a companion half for the engine bindings, and delta as a double rather than a float. The cast to float when it meets a Vector2 is the most common first-day compile error.
Reaching another node
The $Sprite2D shorthand has no C# equivalent — paths are always strings. In exchange, GetNode<T> takes the expected type, so what comes back is already typed. Both columns build the node first so the lookup has something to find.
extends Node2D func _ready() -> void: var made := Sprite2D.new() made.name = "Sprite2D" add_child(made) var sprite := $Sprite2D var same = get_node("Sprite2D") print(sprite.name) print(sprite == same)
using Godot; public partial class Player : Node2D { public override void _Ready() { var made = new Sprite2D(); made.Name = "Sprite2D"; AddChild(made); var sprite = GetNode<Sprite2D>("Sprite2D"); var same = GetNode("Sprite2D"); GD.Print(sprite.Name); GD.Print(sprite == same); } }
That type parameter is doing real work: the returned value has the node's own members without a cast, and a path pointing at the wrong kind of node fails immediately with a clear message rather than at the first property you touch.
A signal is a C# event
Underneath the engine wrapper, a signal is the observer pattern, and C# has it built in as event. Connecting is += and disconnecting is -=.
class Doorbell: signal rung(label: String) func press(label: String) -> void: rung.emit(label) var bell := Doorbell.new() bell.rung.connect(func(label): print("rung: %s" % label)) bell.press("front door")
var bell = new Doorbell(); bell.Rung += label => Console.WriteLine($"rung: {label}"); bell.Press("front door"); class Doorbell { public event Action<string>? Rung; public void Press(string label) => Rung?.Invoke(label); }
The ?.Invoke is there because an event with no subscribers is null — firing into nothing has to be written down rather than silently doing nothing as emit does. The handler's parameter types are checked when you subscribe, not when the signal fires.
A signal the editor can see
For the editor to list a signal in the Node dock, it has to be declared where the engine can find it: a [Signal] delegate whose name ends in EventHandler. The generated half of the class turns that into the HealthChanged event you subscribe to.
extends Node signal health_changed(amount: int) func _ready() -> void: health_changed.connect(_on_health_changed) health_changed.emit(-10) func _on_health_changed(amount: int) -> void: print("health changed by %d" % amount)
using Godot; public partial class Player : Node { [Signal] public delegate void HealthChangedEventHandler(int amount); public override void _Ready() { HealthChanged += OnHealthChanged; EmitSignal(SignalName.HealthChanged, -10); } private void OnHealthChanged(int amount) { GD.Print($"health changed by {amount}"); } }
The EventHandler suffix is required and then dropped — declare HealthChangedEventHandler, use HealthChanged. SignalName.HealthChanged is a generated constant, so emitting a signal you have not declared is a compile error rather than a silent no-op.
Putting a value in the inspector
@export becomes the [Export] attribute, and the field still appears in the inspector exactly as before — the attribute is what the editor reads.
extends Node @export var speed: float = 200.0 @export var title: String = "Level One" @export_range(0, 100) var health: int = 100 func _ready() -> void: print(speed)
using Godot; public partial class Player : Node { [Export] public float Speed = 200.0f; [Export] public string Title = "Level One"; [Export(PropertyHint.Range, "0,100")] public int Health = 100; public override void _Ready() { GD.Print(Speed); } }
The hint variants translate as arguments rather than as separate annotation names: @export_range(0, 100) is [Export(PropertyHint.Range, "0,100")]. The field must be public, since the editor cannot reach a private one.
Two lifetimes now overlap
This is the one genuinely new hazard, and it has no GDScript counterpart. A Godot object freed by the engine leaves behind a C# wrapper that the garbage collector has not collected — so the variable is not null, it points at something whose engine half is gone.
extends Node func _ready() -> void: var enemy := Node.new() print(is_instance_valid(enemy)) enemy.free() print(is_instance_valid(enemy)) print(enemy == null)
using Godot; public partial class Spawner : Node { public override void _Ready() { var enemy = new Node(); GD.Print(GodotObject.IsInstanceValid(enemy)); enemy.Free(); GD.Print(GodotObject.IsInstanceValid(enemy)); GD.Print(enemy == null); } }
Look at the last line of each column: the variable is not null after the object is gone. In GDScript that leaves you a freed reference; in C# it leaves a live wrapper whose engine half has been destroyed, and touching a property on it throws ObjectDisposedException. The check is GodotObject.IsInstanceValidis_instance_valid under its C# name — and it is worth reaching for anywhere a reference outlives a QueueFree. Nothing about .NET's garbage collector removes the need for it.
Drawing It, Side by Side
Building a menu, live
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 in the same engine; read them as one program in two spellings, because that is what they are.
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)
using Godot; public partial class Menu : Control { public override void _Ready() { var pane = GetViewportRect().Size; var column = new VBoxContainer(); column.Position = new Vector2(28, 22); column.Size = pane - new Vector2(56, 44); column.AddThemeConstantOverride("separation", 10); var chosen = new Label(); chosen.Text = "Pick one — the buttons work"; chosen.HorizontalAlignment = HorizontalAlignment.Center; chosen.AddThemeFontSizeOverride("font_size", 18); foreach (var caption in new[] { "Continue", "New game", "Options", "Quit" }) { var entry = new Button(); entry.Text = caption; entry.SizeFlagsVertical = SizeFlags.ExpandFill; entry.AddThemeFontSizeOverride("font_size", 20); entry.Pressed += () => chosen.Text = $"You chose {caption}"; column.AddChild(entry); } column.AddChild(chosen); AddChild(column); } }
Every call has the same name with different capitalization, and entry.pressed.connect(...) becomes entry.Pressed += ... because a signal is an event on this side. The one real translation is the enum: Control.SIZE_EXPAND_FILL is SizeFlags.ExpandFill, since C# enums are nested types rather than integer constants on the class.
Drawing straight onto the canvas
Below the widget layer a CanvasItem takes drawing commands directly. The hue is chosen at random, so every press of the run button gives a different color — the figure draws once and then stops.
extends Control var points: PackedVector2Array = [] var tints: PackedColorArray = [] var turn := 0.0 var hue := randf() func _ready() -> void: set_process(true) func _process(_delta: float) -> void: var pane := get_viewport_rect().size if turn >= TAU * 3.0: set_process(false) # for an endless show, reset turn instead return for step in 8: turn += 0.02 var radius := minf(pane.x, pane.y) * 0.42 * (turn / (TAU * 3.0)) points.append(pane / 2.0 + radius * Vector2(cos(turn * 3.0), sin(turn * 2.0))) tints.append(Color.from_hsv(fposmod(hue + turn * 0.05, 1.0), 0.65, 1.0)) queue_redraw() func _draw() -> void: if points.size() > 1: draw_polyline_colors(points, tints, 2.0, true)
using Godot; public partial class Spiral : Control { private Vector2[] points = System.Array.Empty<Vector2>(); private Color[] tints = System.Array.Empty<Color>(); private float turn = 0.0f; private float hue = GD.Randf(); public override void _Process(double delta) { var pane = GetViewportRect().Size; if (turn >= Mathf.Tau * 3.0f) { SetProcess(false); // for an endless show, reset turn instead return; } for (int step = 0; step < 8; step++) { turn += 0.02f; float radius = Mathf.Min(pane.X, pane.Y) * 0.42f * (turn / (Mathf.Tau * 3.0f)); points = [.. points, pane / 2.0f + radius * new Vector2(Mathf.Cos(turn * 3.0f), Mathf.Sin(turn * 2.0f))]; tints = [.. tints, Color.FromHsv(Mathf.PosMod(hue + turn * 0.05f, 1.0f), 0.65f, 1.0f)]; } QueueRedraw(); } public override void _Draw() { if (points.Length > 1) { DrawPolylineColors(points, tints, 2.0f, true); } } }
This row is also the clearest case for not reaching for C#. The per-frame work is eight points and one draw_polyline_colors, so the build step buys nothing. The rule worth taking away: move to 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, because every call across the boundary has a conversion cost that GDScript does not pay.