PONYλM2Modula-2

JavaScript.CodeCompared.To/GDScript

An interactive executable cheatsheet comparing JavaScript and GDScript

JavaScript (ES2025) GDScript 4.5 (Godot 4.5.2)
Output & Running It
Hello, World
GDScript is the language built into the Godot engine, and this page is written for someone who writes JavaScript for the web and is looking at Godot for the first time. The GDScript column below runs in a real Godot engine compiled to WebAssembly — not a transpiler and not a sandbox pretending to be one.
console.log("Hello, World!");
print("Hello, World!")
Nothing surprising yet, which is the point of starting here. GDScript is deliberately Python-flavored: no semicolons, no braces, and indentation decides what belongs to what. The bigger difference is where the code lives, and the Scene Tree section is where that story starts — in a Godot project a file of GDScript is attached to a node, and the engine calls into it rather than the other way round.
Interpolating values into a string
JavaScript has one interpolation mechanism and it is a template literal. GDScript has printf-style formatting, which will look like a step backwards until the second example.
const name = "Ada"; const score = 1200; console.log(`${name} scored ${score}`); console.log(`${(1.5).toFixed(2)} seconds`); console.log("plain " + name);
var name := "Ada" var score := 1200 print("%s scored %d" % [name, score]) print("%.2f seconds" % 1.5) print("plain " + name)
The % operator takes a single value on the right, or an Array when there is more than one — and unlike a template literal it carries a format: %.2f is toFixed(2), %05d is padStart(5, "0"), and both are checked against the value's type when the script loads. There is also "{name} scored {score}".format({ "name": name }) for named placeholders. What there is not is an expression inside the string — everything interpolated has to be a value you already have.
Inspecting a value while debugging
Both columns print a structure rather than a line, which is what debugging actually looks like.
const settings = { fullscreen: true, volume: 0.8 }; console.log(settings); console.log(JSON.stringify(settings)); console.log([1, 2, 3]);
var settings := { "fullscreen": true, "volume": 0.8 } print(settings) print(JSON.stringify(settings)) print([1, 2, 3]) print(var_to_str(settings))
print already renders a Dictionary or an Array readably, so there is no console.dir to reach for. var_to_str is the closest thing to JSON.stringify with indentation — it produces GDScript's own literal syntax, which round-trips through str_to_var. Godot also ships a real JSON class when the destination is a file or a network peer rather than your own eyes.
Variables & Optional Static Types
let, const — and just var
GDScript has one declaration keyword for anything that changes and one for anything that does not, and the split lands in a different place than JavaScript's.
let health = 100; const maxHealth = 100; health -= 30; console.log(health, maxHealth);
var health := 100 const MAX_HEALTH := 100 health -= 30 print(health, " ", MAX_HEALTH)
GDScript's const is stricter than JavaScript's: it must be initialized with a value the compiler can work out at load time, so const NOW = Time.get_ticks_msec() is a load-time error rather than a constant that happens to be computed once. That also means there is no const for "this reference will not be reassigned but its contents will change" — a Dictionary you intend to mutate is a var. The screaming-case name is a convention rather than a rule, but the engine's own source follows it everywhere.
The := that is not what you think
The colon-equals is the single most important piece of GDScript syntax to understand early, and it is not an assignment operator.
// JavaScript infers nothing at run time — a binding // holds whatever you last put in it. let speed = 200; speed = "fast"; console.log(typeof speed);
# := infers the type ONCE, at load time, and then holds you to it. var speed := 200.0 print(typeof(speed) == TYPE_FLOAT) # An untyped var behaves like a JavaScript binding. var anything = 200.0 anything = "fast" print(typeof(anything) == TYPE_STRING)
var speed := 200.0 means "infer this variable's type from its first value and check every later assignment against it". Assigning a String to it afterwards is an error caught when the script loads, before the game runs. A bare var speed = 200.0 is the JavaScript binding you already know — it holds a Variant, which is Godot's tagged union of every type. The reason to prefer := is not only safety: an annotated variable skips Variant dispatch and the difference is commonly quoted at two to four times, which matters when the code runs sixty times a second.
Writing the type out
Inference is not always what you want. This is the same annotation written out by hand.
/** @type {number} */ let speed = 200; /** @type {string[]} */ const names = ["Ada", "Grace"]; console.log(speed, names.length);
var speed: float = 200.0 var names: Array[String] = ["Ada", "Grace"] print(speed, " ", names.size())
This is the same annotation := produced, written out — worth doing when the inferred type would be wrong (var speed := 200 infers int, not float) or when the declaration has no initializer. Array[String] is a genuinely typed array: appending an int to it fails, where a JavaScript array with a JSDoc annotation only fails if someone runs a type checker over it. A JavaScript developer will recognize the trade — this is what TypeScript does, except that the checking is part of the language rather than a build step, and it is optional per variable rather than per file.
null, and no undefined
JavaScript's two empty values are a famous piece of accidental history. GDScript has one.
const settings = { volume: 0.8 }; console.log(settings.volume); console.log(settings.missing); // undefined console.log(settings.missing ?? 1.0); // 1 console.log(null == undefined); // true
var settings := { "volume": 0.8 } print(settings["volume"]) print(settings.get("missing")) # <null> print(settings.get("missing", 1.0)) # 1 print(null == null)
There is no undefined in GDScript, so the whole family of distinctions that follows from it — ?? against ||, == null catching both, a missing property differing from a property set to null — simply does not arise. Reading a key that is not in a Dictionary with settings["missing"] is an error rather than undefined; get(key, fallback) is the deliberate way to ask, and it plays the role of ??.
Asking what something is
Both languages can be asked what a value is at run time, and they answer at different resolutions.
console.log(typeof 1); // number console.log(typeof "a"); // string console.log(Array.isArray([])); // true console.log([] instanceof Array); // true
print(type_string(typeof(1))) # int print(type_string(typeof(1.0))) # float print(type_string(typeof("a"))) # String print([] is Array) print(1 is int)
GDScript's typeof returns an integer from the Variant.Type enum, and type_string turns it into something readable — so it is closer to Object.prototype.toString.call than to JavaScript's typeof, in that it distinguishes types JavaScript collapses. is is the operator to reach for in real code: it works for built-in types and for your own classes, the way instanceof does, and unlike instanceof it also works for the primitives.
Numbers: One Type vs. Two
🚨 7 / 2 is 3
This is the single difference most likely to produce a wrong number in a JavaScript developer's first week of GDScript, and it is silent.
console.log(7 / 2); // 3.5 console.log(Math.floor(7 / 2)); // 3 console.log(7 % 2); // 1
print(7 / 2) # 3 — two ints divide as ints print(7.0 / 2) # 3.5 — one float is enough print(float(7) / 2) # 3.5 — the deliberate way to ask print(7 % 2) # 1
JavaScript has one number type, so division always produces the mathematical answer. GDScript has int and float as genuinely separate types, and dividing two ints performs integer division — 7 / 2 is 3, with no warning, because that is what a C-family language does and Godot's engine is C++. Make one side a float and you get the answer you expected. The habit worth forming is writing game constants as 200.0 rather than 200, which is also why var speed := 200.0 appears throughout this page.
Math.* vs. plain global functions
The mathematics is identical; only where the functions live differs.
console.log(Math.floor(3.7)); console.log(Math.round(3.5)); console.log(Math.abs(-4)); console.log(Math.min(3, 9), Math.max(3, 9)); console.log(Math.sqrt(16)); console.log(Math.PI.toFixed(4));
print(floor(3.7)) print(round(3.5)) print(abs(-4)) print(min(3, 9), " ", max(3, 9)) print(sqrt(16)) print("%.4f" % PI)
Godot puts the whole of Math in the global scope, so there is no namespace to type. There are also typed variants — floori, roundi, absi, mini, maxi — that return an int rather than a Variant, which is what you want inside a := declaration or a hot loop. Two more worth knowing early because game code is full of them: clamp(value, low, high), and lerp(from, to, weight) for interpolating between two values.
Random numbers
A game needs randomness constantly, and it needs one thing from it that a web page usually does not.
// Seedless: Math.random cannot be made reproducible. const roll = Math.floor(Math.random() * 6) + 1; console.log(roll >= 1 && roll <= 6); const names = ["Ada", "Grace", "Alan"]; console.log(names.includes( names[Math.floor(Math.random() * names.length)] ));
seed(1) # reproducible, which matters for a game var roll := randi_range(1, 6) print(roll >= 1 and roll <= 6) var names := ["Ada", "Grace", "Alan"] print(names.has(names.pick_random())) print(randf() >= 0.0 and randf() < 1.0)
The interesting difference is seed(). Math.random deliberately cannot be seeded, so a JavaScript program that needs reproducible randomness has to bring its own generator. A game engine needs reproducibility for replays, for procedural generation and for tests, so Godot exposes the seed — and gives you randi_range, randf_range and Array.pick_random() so the off-by-one arithmetic in the JavaScript column never has to be written.
The type JavaScript does not have: Vector2
Game code is mostly arithmetic on pairs and triples of numbers, and this is where a language built for it starts to show.
// JavaScript has no vector type, so a position is a // plain object and the arithmetic is written out. const position = { x: 100, y: 300 }; const velocity = { x: 5, y: -2 }; const moved = { x: position.x + velocity.x, y: position.y + velocity.y }; console.log(moved.x, moved.y); console.log(Math.hypot(moved.x, moved.y).toFixed(2));
var position := Vector2(100, 300) var velocity := Vector2(5, -2) var moved := position + velocity print(moved) print("%.2f" % moved.length()) print(moved.normalized().length())
Vector2, Vector3, Rect2, Color and Transform2D are value types built into the language with operators — they are copied on assignment, they add and scale with + and *, and they carry the methods the arithmetic keeps needing (length, normalized, distance_to, angle_to, lerp). None of that is a library; there is nothing to import. A JavaScript developer will notice the absence of new, and it is meaningful: these do not allocate on the heap.
Strings
The everyday string methods
The everyday string operations exist on both sides under different names. This row is mostly a translation table.
const text = "Hello, Godot"; console.log(text.length); console.log(text.toUpperCase()); console.log(text.includes("Godot")); console.log(text.startsWith("Hello")); console.log(text.replace("Godot", "world")); console.log(text.trim());
var text := "Hello, Godot" print(text.length()) print(text.to_upper()) print(text.contains("Godot")) print(text.begins_with("Hello")) print(text.replace("Godot", "world")) print(text.strip_edges())
The list is the same list with different names, and the naming is the point: GDScript is snake_case throughout, and length() is a method rather than a property, so the parentheses are not optional. The three renames worth memorizing are contains for includes, begins_with / ends_with for startsWith / endsWith, and strip_edges for trim.
Taking a piece of a string
Both languages take a slice out of a string, and their second arguments do not mean the same thing.
const text = "Hello, Godot"; console.log(text.slice(7)); console.log(text.slice(0, 5)); console.log(text.at(-1)); console.log(text[0]);
var text := "Hello, Godot" print(text.substr(7)) print(text.substr(0, 5)) print(text[-1]) print(text[0])
The shape is the same but the second argument is not: slice(0, 5) takes an end index while substr(0, 5) takes a length. They agree here only because the start is zero. Negative indexing works with the bracket syntax in both languages, which is why text[-1] needs no at() on the GDScript side.
Splitting and joining
Splitting is the same idea in both. Joining is spelled backwards from what a JavaScript developer expects.
const line = "Ada,Grace,Alan"; const names = line.split(","); console.log(names); console.log(names.join(" and ")); console.log("7".padStart(3, "0"));
var line := "Ada,Grace,Alan" var names := line.split(",") print(names) print(" and ".join(names)) print("7".pad_zeros(3))
The surprise is join. In JavaScript it is a method on the array; in GDScript it is a method on the separator, so it reads " and ".join(names) — which is Python's spelling and takes a day to stop looking backwards. Note also that split returns a PackedStringArray, a compact contiguous array of strings with no Variant per element; it behaves like an Array for everything on this page.
Turning things into strings, and back
JavaScript's + coerces, which is the origin of a decade of jokes. GDScript's does not, and refuses at load time.
console.log("count: " + 3); // implicit coercion console.log(String(3.5)); console.log(Number("42") + 1); console.log(parseInt("42px", 10)); console.log(Number("nonsense")); // NaN
print("count: " + str(3)) # + will NOT coerce for you print(str(3.5)) print("42".to_int() + 1) print("42px".to_int()) print("nonsense".to_int()) # 0, not NaN
"count: " + 3 is an error in GDScript, caught when the script loads rather than producing "count: 3": + between a String and an int has no meaning, so you write str(3) and say what you meant. Going the other way, to_int() never produces a NaN — there is no such value for an int — so unparseable input becomes 0, which is quieter than JavaScript and worth a guard when the input came from a player. "42".is_valid_int() is that guard.
Arrays
Creating, reading, growing
The array operations you reach for without thinking, and what they are called here.
const scores = [10, 20, 30]; console.log(scores[0], scores.at(-1)); console.log(scores.length); scores.push(40); console.log(scores.pop()); console.log(scores);
var scores := [10, 20, 30] print(scores[0], " ", scores[-1]) print(scores.size()) scores.append(40) print(scores.pop_back()) print(scores)
Three renames and one convenience. length is size(), push is append, and pop is pop_back (with pop_front for shift and push_front for unshift). The convenience is that scores[-1] just works, so there is no at() and no scores[scores.length - 1].
map, filter, reduce
This is more familiar than a JavaScript developer expects — Godot 4 gave Array the higher-order methods it had been missing.
const numbers = [1, 2, 3, 4]; console.log(numbers.map((n) => n * 2)); console.log(numbers.filter((n) => n % 2 === 0)); console.log(numbers.reduce((total, n) => total + n, 0)); console.log(numbers.some((n) => n > 3)); console.log(numbers.find((n) => n > 2));
var numbers := [1, 2, 3, 4] print(numbers.map(func(n): return n * 2)) print(numbers.filter(func(n): return n % 2 == 0)) print(numbers.reduce(func(total, n): return total + n, 0)) print(numbers.any(func(n): return n > 3)) print(numbers.filter(func(n): return n > 2)[0])
The correspondence is nearly exact: map, filter and reduce are the same, some is any and every is all. The two gaps are find — GDScript's find searches for a value, not by predicate, so the filter-then-index above is the idiom — and the fact that these return an untyped Array even when the input was Array[int]. The lambda syntax is covered in the Functions section, and the important thing about it is on the next line.
Sorting
Both sort in place. The comparator is where the two part company, and the difference is easy to miss.
const names = ["Grace", "Ada", "Alan"]; names.sort(); console.log(names); const players = [ { name: "Ada", score: 1200 }, { name: "Alan", score: 640 }, { name: "Grace", score: 980 }, ]; players.sort((a, b) => b.score - a.score); console.log(players.map((p) => p.name));
var names := ["Grace", "Ada", "Alan"] names.sort() print(names) var players := [ { "name": "Ada", "score": 1200 }, { "name": "Alan", "score": 640 }, { "name": "Grace", "score": 980 }, ] players.sort_custom(func(a, b): return a["score"] > b["score"]) print(players.map(func(player): return player["name"]))
Both sort in place and return nothing useful, so the JavaScript habit of not writing const sorted = names.sort() transfers directly. The comparator does not: JavaScript wants a number whose sign decides the order, GDScript wants a boolean answering "does a come before b". Returning a number from sort_custom quietly sorts by truthiness, which is the kind of bug that looks like a sorting-algorithm problem for an hour.
Combining and copying
There is no spread operator, so combining and copying are two separate methods.
const first = [1, 2]; const second = [3, 4]; console.log([...first, ...second]); const copy = [...first]; copy.push(99); console.log(first, copy);
var first := [1, 2] var second := [3, 4] print(first + second) var copy := first.duplicate() copy.append(99) print(first, " ", copy)
There is no spread operator, and two things fill the gap: + concatenates two Arrays into a new one, and append_array merges one into another in place. The copying half matters more. var copy := first makes both names refer to the same Array, exactly as const copy = first would in JavaScript — duplicate() is the deliberate copy, and duplicate(true) is the deep one.
The array type JavaScript reserves for typed arrays
JavaScript's typed arrays exist for binary interoperation and are rarely reached for. Godot's equivalent is an everyday performance tool.
// A plain array holds anything; a typed array is a // separate, contiguous thing you opt into. const loose = [1, 2, 3]; const packed = new Int32Array([1, 2, 3]); console.log(loose.length, packed.length); console.log(packed[0] + packed[2]); console.log(packed.byteLength);
var loose := [1, 2, 3] var packed := PackedInt32Array([1, 2, 3]) print(loose.size(), " ", packed.size()) print(packed[0] + packed[2]) print(packed.to_byte_array().size())
A Packed*Array is a real contiguous buffer with no Variant per element — the same idea as Int32Array, and the family (PackedByteArray, PackedInt32Array, PackedFloat32Array, PackedVector2Array, PackedStringArray) is what the engine's own APIs hand back. Reaching for one is often the difference between "this needs to be written in C++" and "this is fine in GDScript"; the cost is that the element type is fixed and the array is copied on assignment rather than shared.
Objects vs. Dictionaries
An object literal is a Dictionary
The literal syntax is close enough to copy and paste, with one required change.
const player = { name: "Ada", score: 1200 }; console.log(player.name); console.log(player["score"]); player.level = 3; console.log(Object.keys(player)); console.log(Object.values(player));
var player := { "name": "Ada", "score": 1200 } print(player["name"]) print(player.score) player["level"] = 3 print(player.keys()) print(player.values())
The keys need quotes. A JavaScript object literal lets you write { name: "Ada" } and treats the bare word as the string; GDScript reserves that spelling for its own Lua-style form and wants { "name": "Ada" }. Reading works both ways — player.score and player["score"] are the same lookup — but writing a new key must use brackets, because player.level = 3 on a Dictionary is a property assignment that fails. Object.keys and Object.values become methods on the Dictionary itself.
Asking for a key that might not be there
JavaScript hands back undefined for a key that is not there. GDScript raises the stakes.
const settings = { volume: 0.8 }; console.log("volume" in settings); console.log(settings.missing); // undefined console.log(settings.missing ?? 1.0); console.log(settings?.nested?.deep); // undefined
var settings := { "volume": 0.8 } print(settings.has("volume")) print(settings.get("missing")) # <null> print(settings.get("missing", 1.0)) print(settings.get("nested", {}).get("deep"))
There is no optional chaining, and its absence is felt: a chain of ?. becomes a chain of get(key, {}), or a guard clause, or a typed class instead of a nested Dictionary — which is usually the right answer in a codebase that is going to be maintained. Note the harder edge too: settings["missing"] is an error rather than undefined, so has and get are not stylistic choices.
Iterating over the pairs
Iterating a Dictionary directly yields its keys, which is for…in rather than for…of.
const player = { name: "Ada", score: 1200 }; for (const key of Object.keys(player)) { console.log(`${key} = ${player[key]}`); } for (const [key, value] of Object.entries(player)) { console.log(`${key} -> ${value}`); }
var player := { "name": "Ada", "score": 1200 } for key in player: print("%s = %s" % [key, player[key]]) for key in player.keys(): print("%s -> %s" % [key, player[key]])
Iterating a Dictionary directly yields its keys, which is the behavior a JavaScript developer expects from for…in rather than from for…of. There is no Object.entries and no destructuring in the loop header, so the value is fetched by key. Both languages preserve insertion order, which is a guarantee JavaScript only made official in ES2015 and Godot has always had.
When a Dictionary should have been a class
A JavaScript developer reaches for an object literal by default. In GDScript that default costs something specific.
// JavaScript: a plain object and a class instance are // close enough that the choice is often stylistic. const asObject = { name: "Ada", score: 1200 }; class Player { constructor(name, score) { this.name = name; this.score = score; } } const asInstance = new Player("Ada", 1200); console.log(asObject.name, asInstance.name);
# GDScript: the choice is NOT stylistic. A Dictionary key # is checked at run time; a class member is checked at load. var as_dictionary := { "name": "Ada", "score": 1200 } class Player: var name: String var score: int func _init(new_name: String, new_score: int) -> void: name = new_name score = new_score var as_instance := Player.new("Ada", 1200) print(as_dictionary["name"], " ", as_instance.name)
Misspelling as_dictionary["nmae"] is a run-time error that happens the moment that line executes — which, in a game, may be the moment a rare branch is taken. Misspelling as_instance.nmae is caught when the script loads, before anything runs. On top of that, a typed class member has a real type and skips Variant dispatch. The rule of thumb: a Dictionary is for data whose shape you learn at run time (a save file, a JSON response); anything whose shape you know while writing the code should be a class.
Control Flow & match
if, else, and the conditional expression
The same three-branch decision, without a brace or a parenthesis in sight.
const health = 30; if (health <= 0) { console.log("dead"); } else if (health < 50) { console.log("hurt"); } else { console.log("fine"); } console.log(health < 50 ? "hurt" : "fine");
var health := 30 if health <= 0: print("dead") elif health < 50: print("hurt") else: print("fine") print("hurt" if health < 50 else "fine")
No parentheses, no braces, a colon, and elif. The conditional expression is the same three pieces in a different order — value if condition else other — which reads more like a sentence and less like an operator, and nests far worse, which is arguably the point. The boolean operators are spelled and, or and not; && and || also work and the word forms are what the engine's own code uses.
🚨 An empty array is falsy
The falsy sets almost line up. The place they do not is the one JavaScript developers rely on without noticing.
for (const value of [0, 1, "", "a", [], [1], {}, null]) { console.log(JSON.stringify(value), Boolean(value)); } // [] and {} are TRUTHY in JavaScript.
for value in [0, 1, "", "a", [], [1], {}, null]: print(var_to_str(value), " ", true if value else false) # [] and {} are FALSY in GDScript.
JavaScript's [] and {} are objects and objects are truthy, which is why if (list) is a null check and if (list.length) is an emptiness check. GDScript makes an empty Array and an empty Dictionary falsy, so if list: is the emptiness check — and a habitual if (list) guard translated literally will now skip the branch for an empty-but-perfectly-valid list. GDScript is also stricter about ==: there is no coercion, so "1" == 1 is simply false and there is no === to reach for.
Loops
Every loop a JavaScript developer writes has a GDScript form, and one of them is missing entirely.
for (let i = 0; i < 3; i++) console.log(i); for (const name of ["Ada", "Grace"]) console.log(name); let countdown = 3; while (countdown > 0) { countdown -= 1; } console.log(countdown);
for index in 3: print(index) for name in ["Ada", "Grace"]: print(name) var countdown := 3 while countdown > 0: countdown -= 1 print(countdown)
There is no three-clause for and no ++. for index in 3 counts 0, 1, 2, and for index in range(2, 10, 2) covers the general case. Everything else is a for…of under a shorter name — iterating an Array yields its elements, iterating a Dictionary yields its keys, iterating a String yields its characters. break and continue are unchanged.
switch, and the match that outgrows it
This is the row where GDScript is plainly ahead, and it is worth reading the target column slowly.
// JavaScript's switch compares one value with ===, // and falls through unless you break. function describe(command) { switch (command[0]) { case "move": return `move to ${command[1]},${command[2]}`; case "stop": return "stop"; default: return "unknown"; } } console.log(describe(["move", 3, 4])); console.log(describe(["wait"]));
func describe(command: Array) -> String: match command: ["move", var x, var y]: return "move to %d,%d" % [x, y] ["stop"]: return "stop" _: return "unknown" print(describe(["move", 3, 4])) print(describe(["wait"]))
match is real pattern matching, not a switch. A pattern can destructure an Array or a Dictionary and bind the pieces (var x, var y), match a type (var value is int), list alternatives (1, 2, 3), or end with .. to mean "and anything else". There is no fall-through and therefore no break, and _ is the default branch. The closest JavaScript equivalent is destructuring assignment plus a chain of ifs, which is exactly what it replaces.
Functions, Arrows & Callables
Declaring a function
A function declaration, with the optional type annotations that make GDScript check it for you.
function doubled(value) { return value * 2; } function greet(name, greeting = "Hello") { return `${greeting}, ${name}!`; } console.log(doubled(21)); console.log(greet("Ada")); console.log(greet("Ada", "Hi"));
func doubled(value: int) -> int: return value * 2 func greet(name: String, greeting: String = "Hello") -> String: return "%s, %s!" % [greeting, name] print(doubled(21)) print(greet("Ada")) print(greet("Ada", "Hi"))
Default parameters work the same way. The annotations are optional and worth writing: -> int is checked at load time against every return in the body, and a call passing the wrong type is caught then too rather than at the call. -> void says the function returns nothing, which the compiler will then hold you to. What GDScript does not have is rest parameters — no ...args — so a function that takes an unknown number of things takes an Array.
Arrow functions vs. Callables
Anonymous functions exist and are used constantly, but calling one does not look like calling a function.
const double = (n) => n * 2; const add = (a, b) => a + b; const shout = (text) => { const loud = text.toUpperCase(); return `${loud}!`; }; console.log(double(21)); console.log(add(2, 3)); console.log(shout("hey"));
var double := func(n): return n * 2 var add := func(a, b): return a + b var shout := func(text): var loud: String = text.to_upper() return "%s!" % loud print(double.call(21)) print(add.call(2, 3)) print(shout.call("hey"))
A lambda produces a Callable — a value holding a function together with the object it belongs to — and a Callable is invoked with .call(…) rather than with parentheses. That extra word is the price of GDScript not having a callable-object type: double(21) would mean "call the method named double", which is a different thing. A named method also converts to a Callable simply by naming it without parentheses, which is how scored.connect(_on_scored) in the Scene Tree section works.
🚨 A lambda captures by value
Both columns look identical and print different numbers. This is the one to read twice.
let factor = 2; const double = (n) => n * factor; factor = 10; console.log(double(5)); // 50 — the closure sees the // current value of the binding.
var factor := 2 var double := func(n): return n * factor factor = 10 print(double.call(5)) # 10 — the lambda kept a COPY of # factor as it was when it was made.
A JavaScript closure captures the binding, so it sees whatever the variable holds when the closure runs — which is why the loop-variable bug existed before let, and why a closure can be used to share mutable state. A GDScript lambda captures the value at the moment it is created, so it is a snapshot. Neither is wrong, but a JavaScript habit built on the first will silently produce stale numbers under the second. When a lambda genuinely needs to see later changes, give it something whose contents change — an Array, a Dictionary, or the enclosing object's own member, which it reaches through self rather than through capture.
Passing a function to something else
Higher-order functions work in both, and the thing JavaScript developers lose sleep over — a lost this — has no equivalent here.
function applyTwice(fn, value) { return fn(fn(value)); } const increment = (n) => n + 1; console.log(applyTwice(increment, 5)); console.log(applyTwice((n) => n * 3, 2)); // Bound methods need care. const counter = { total: 0, add(n) { this.total += n; return this.total; } }; const bound = counter.add.bind(counter); console.log(bound(4));
func apply_twice(callback: Callable, value: int) -> int: return callback.call(callback.call(value)) func increment(n: int) -> int: return n + 1 print(apply_twice(increment, 5)) print(apply_twice(func(n): return n * 3, 2)) # A method reference is ALREADY bound to its object. var total := 0 var add := func(n): total += n return total print(add.call(4))
Naming a method without parentheses produces a Callable that already remembers its object, so there is no bind and no lost-this bug — the single most common cause of a broken JavaScript event handler simply does not exist here. GDScript's own bind means something else entirely: callback.bind(extra) pre-supplies trailing arguments, which is JavaScript's partial application rather than its this-binding.
Classes & Objects
A class, and the file it lives in
The shape is familiar. What is different is that in a real project the class is not written inside another file — the file is the class.
class Player { constructor(name, health = 100) { this.name = name; this.health = health; } damage(amount) { this.health -= amount; } } const player = new Player("Ada"); player.damage(30); console.log(player.name, player.health);
class_name Player extends RefCounted var name: String var health: int = 100 func _init(new_name: String, new_health: int = 100) -> void: name = new_name health = new_health func damage(amount: int) -> void: health -= amount var player := Player.new("Ada") player.damage(30) print(player.name, " ", player.health)
In a Godot project this would be the whole of player.gd: class_name Player registers the name globally so any other script can say Player.new(), and extends RefCounted names the base class — which is also the memory strategy. RefCounted is reference counted and disappears when nothing points at it, Node is owned by its parent in the scene tree, and plain Object must be freed by hand. There is no new keyword; Player.new() is an ordinary static call. And there is no this — members are reachable by name, with self available when you need the object itself.
Inheritance and super
Class inheritance is close enough to a transliteration that it is worth reading for the differences rather than the similarities.
class Animal { constructor(name) { this.name = name; } speak() { return `${this.name} makes a sound`; } } class Dog extends Animal { constructor(name) { super(name); } speak() { return `${super.speak()} — a bark`; } } console.log(new Dog("Rex").speak());
class Animal: var name: String func _init(new_name: String) -> void: name = new_name func speak() -> String: return "%s makes a sound" % name class Dog extends Animal: func _init(new_name: String) -> void: super(new_name) func speak() -> String: return "%s — a bark" % super.speak() print(Dog.new("Rex").speak())
Almost a transliteration. super(args) calls the parent constructor and super.method() calls the parent's version, exactly as in JavaScript. The differences are that a class declared inside another file is written class Dog extends Animal: rather than being a file of its own, and that GDScript has no interfaces and no multiple inheritance — the composition answer is to attach several nodes to one object, which is the engine's own preferred design and is what the Scene Tree section is about.
Getters and setters
JavaScript needs a private field and a pair of accessors around it. GDScript attaches the accessors to the variable itself.
class Health { #current = 100; get current() { return this.#current; } set current(value) { this.#current = Math.max(0, Math.min(100, value)); } } const health = new Health(); health.current = 150; console.log(health.current); health.current = -5; console.log(health.current);
var current := 100: set(value): current = clampi(value, 0, 100) get: return current func show() -> void: print(current) current = 150 show() current = -5 show()
The set and get blocks hang directly off the declaration, so there is no shadow field to name and no chance of the two drifting apart. This matters more in Godot than the syntax suggests: a setter is how a value that other things depend on stays consistent — assigning to health can update a health bar, emit a died signal and clamp the number, all without any caller knowing. The one trap is that assigning to the variable inside its own setter does not recurse; it writes the backing storage, which is what you want.
Static members
Class-level state and class-level functions, spelled almost identically — and newer in GDScript than you would guess.
class Counter { static total = 0; static increment() { Counter.total += 1; } } Counter.increment(); Counter.increment(); console.log(Counter.total);
class Counter: static var total := 0 static func increment() -> void: total += 1 Counter.increment() Counter.increment() print(Counter.total)
The same idea with the same spelling, and it is newer than a JavaScript developer might guess — static var arrived in Godot 4.1, so older answers on the internet will tell you to use an autoload singleton instead. An autoload is still the right answer for a service that needs to exist for the whole game (an audio manager, a save system): it is a node the engine instantiates at startup and puts at a fixed path, which makes it closer to a module-level singleton than to a static class.
Printing an object readably
Both languages let an object decide how it prints. The GDScript name carries a convention with it.
class Point { constructor(x, y) { this.x = x; this.y = y; } toString() { return `(${this.x}, ${this.y})`; } } const point = new Point(3, 4); console.log(String(point)); console.log(`at ${point}`);
class Point: var x: int var y: int func _init(new_x: int, new_y: int) -> void: x = new_x y = new_y func _to_string() -> String: return "(%d, %d)" % [x, y] var point := Point.new(3, 4) print(point) print("at %s" % point)
GDScript's _to_string is JavaScript's toString with an underscore, and the leading underscore is a real convention rather than decoration: it marks a method the engine calls, alongside _init, _ready, _process and _draw. A JavaScript developer can read it as "this is a lifecycle hook, not part of my own interface".
Exceptions vs. No Exceptions
🚨 There is no throw, and no try
This is the largest single difference on the page, and it is a design decision rather than an omission.
function parseScore(text) { const value = Number(text); if (Number.isNaN(value)) { throw new Error(`not a number: ${text}`); } return value; } console.log(parseScore("3")); try { parseScore("boss"); } catch (error) { console.log("handled:", error.message); }
func parse_score(text: String) -> int: if not text.is_valid_int(): push_error("not a number: %s" % text) return -1 # a sentinel the caller must check return text.to_int() print(parse_score("3")) var result := parse_score("boss") if result == -1: print("handled: the caller checks the return value")
GDScript has no exceptions at all — no throw, no try, no catch, no finally. A failure comes back as a value the caller inspects: an Error enum member (OK, ERR_FILE_NOT_FOUND, …), a null, or a sentinel like the -1 above. The reason is a game loop: an exception unwinding through a frame leaves the scene tree in a half-updated state, and a sixty-times-a-second loop has nowhere sensible to put a stack trace. What replaces the safety net is that every caller checks, which is tedious and is why the engine's own APIs return error codes rather than raising. push_error reports the problem to the console and the editor without stopping anything.
The error code the engine actually returns
This is the shape of nearly every fallible API in the engine, so it is worth recognizing on sight.
// JSON.parse throws; you wrap it. try { JSON.parse("{ not json"); } catch (error) { console.log("failed:", error.name); } const ok = JSON.parse('{"volume": 0.8}'); console.log(ok.volume);
# JSON.parse returns a code; you check it. var json := JSON.new() var code := json.parse("{ not json") if code != OK: print("failed: %s" % error_string(code)) if json.parse('{"volume": 0.8}') == OK: print(json.data["volume"])
This is the shape of nearly every fallible engine API: it returns an Error, OK is zero, and the result is fetched separately once the code says it is safe to. error_string turns the code into something printable. There is also a convenience form, JSON.parse_string(text), which returns the parsed value or null — quicker to write, and it prints an engine error of its own when the input is bad, so it belongs in code where bad input is a bug rather than an expected case.
Assertions and the console
The three console levels map one to one. The assertion does not.
console.log("a normal line"); console.warn("something looks off"); console.error("something is wrong"); console.assert(1 + 1 === 2, "arithmetic still works"); console.log("carrying on");
print("a normal line") push_warning("something looks off") push_error("something is wrong") assert(1 + 1 == 2, "arithmetic still works") print("carrying on")
The important difference is that assert is stripped from release builds entirely — its condition is not merely ignored, it is not compiled, so an assertion whose expression has a side effect will change behavior between your debug run and your shipped game. console.assert stays in the bundle and merely logs. The other two are close: push_warning and push_error print to the console and appear in the editor's Debugger panel with a stack trace, which is where they earn their keep.
Promises vs. await on a Signal
Waiting for time to pass
GDScript has await, and a JavaScript developer will reach for it on day one. What it waits on is the interesting part.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); console.log("before"); await wait(50); console.log("after");
print("before") await get_tree().create_timer(0.05).timeout print("after")
There is no Promise. await takes a signal — the same object the Scene Tree section connects handlers to — and suspends the function until that signal fires. create_timer(0.05) makes a one-shot timer object whose timeout signal is what is being awaited, so the line reads as "wait for this timer to go off". A JavaScript developer should notice what is missing: no promise to hold, no .then, no rejection, and therefore no catch — a signal that never fires simply leaves the function suspended forever.
Awaiting something you defined
A JavaScript developer has to build a promise to be able to await something of their own. GDScript does not.
let resolveReady; const ready = new Promise((resolve) => { resolveReady = resolve; }); console.log("waiting"); queueMicrotask(() => resolveReady("done")); ready.then((detail) => console.log(detail));
signal ready_now(detail: String) func announce() -> void: ready_now.emit("done") print("waiting") announce.call_deferred() print(await ready_now)
A signal you declare is awaitable for free — there is no separate promise-shaped thing to construct, and await evaluates to whatever the signal carried. (The JavaScript column uses .then rather than a top-level await only so that it runs as a plain script; in a module the two are interchangeable.) call_deferred is the closest analogue to queueMicrotask: it runs the call at the end of the current frame rather than immediately, which is exactly what is needed here so that the await is already listening. The general lesson is that signals are Godot's single asynchrony mechanism — they are the event system, the promise, and the observer pattern all at once, so learning them once pays three times.
Two very different loops
Both languages are single-threaded and never block. What drives them is not the same thing at all.
// The browser event loop: your code runs when // something happens, and yields between tasks. console.log("first"); queueMicrotask(() => console.log("microtask")); setTimeout(() => console.log("macrotask"), 0); console.log("second");
# The frame loop: _process runs once per frame, # forever, whether or not anything happened. extends Node var frames := 0 func _process(delta: float) -> void: frames += 1 if frames == 3: print("three frames elapsed") set_process(false) func _ready() -> void: print("ready")
A browser runs your code in response to events and is idle in between; a game engine runs a frame sixty times a second whether anything happened or not, and _process(delta) is your slot inside it. delta is the seconds since the previous frame, and multiplying movement by it is what keeps a game running at the same speed on different hardware. The practical consequence for a JavaScript developer: work that would go in a setInterval goes in _process, work that would go in a requestAnimationFrame loop is _process, and blocking the frame is as unforgivable as blocking the event loop — except that here the budget is sixteen milliseconds and it is measured.
The DOM vs. the Scene Tree
createElement / appendChild is Node.new / add_child
This is the heart of the page. A game engine is supposed to be a different world; read the two columns and decide whether it is.
const panel = document.createElement("div"); panel.id = "panel"; const label = document.createElement("span"); label.textContent = "Score"; panel.appendChild(label); document.body.appendChild(panel); console.log(panel.children.length); console.log(label.parentElement.id);
var panel := Control.new() panel.name = "Panel" var label := Label.new() label.text = "Score" panel.add_child(label) add_child(panel) print(panel.get_child_count()) print(label.get_parent().name)
They are the same operation. Godot's scene tree is a retained tree of objects that you mutate — which is the DOM, not a game loop — so Node.new() is createElement, add_child is appendChild, get_parent() is parentElement, and get_child_count() is children.length. A web developer arrives at Godot with a working mental model of the central data structure and is usually told they do not. Two differences worth holding on to: a node's name must be unique among its siblings (the engine renames a duplicate for you), and a node is not anywhere until it is added — an unparented node exists, has no position, and will leak if you drop it.
querySelector is a path
Finding a node you did not just create is where the DOM analogy stops being flattering.
const root = document.createElement("div"); root.innerHTML = ` <div id="hud"><span class="score">0</span></div> `; document.body.appendChild(root); console.log(root.querySelector("#hud .score").textContent); console.log(root.querySelectorAll("span").length);
var hud := Control.new() hud.name = "Hud" var score := Label.new() score.name = "Score" score.text = "0" hud.add_child(score) add_child(hud) print(get_node("Hud/Score").text) print(hud.find_children("*", "Label").size())
There is no selector language. Lookup is by pathget_node("Hud/Score"), abbreviated $Hud/Score in real code — which is a filesystem path through the tree rather than a query over it. That is less expressive and much faster, and it is one of the places Godot pushes you toward structure: a node that needs another node is expected to be handed it (@export var target: Node) or to find it once in _ready, not to search the tree every frame. find_children exists for the genuinely dynamic case and is the thing to avoid in a hot path.
remove() is queue_free()
Detaching is the same. What happens next is where a garbage-collected language and a reference-counted engine part company.
const parent = document.createElement("div"); const child = document.createElement("span"); parent.appendChild(child); console.log(parent.children.length); child.remove(); console.log(parent.children.length); // The element still exists; the garbage collector // takes it when nothing references it. console.log(child.tagName);
var parent := Node.new() var child := Node.new() parent.add_child(child) print(parent.get_child_count()) parent.remove_child(child) print(parent.get_child_count()) # The node still exists, and NOTHING will collect it. print(is_instance_valid(child)) child.free() print(is_instance_valid(child))
A detached DOM element is collected when nothing references it. A detached Node is notNode is manually managed, and a node you removed and forgot is a leak. In practice you rarely call free() as above: queue_free() is the idiom, and it detaches and destroys the node at the end of the current frame, which is safe to call while the engine is in the middle of iterating that node. The pattern to internalize is that queue_free() is remove() and doing only remove_child leaves you holding something you now own.
addEventListener is signal.connect
Both columns define a thing that announces changes and something that listens. One of them is checked before it runs.
class HealthBar extends EventTarget { constructor() { super(); this.health = 3; } damage(amount) { this.health -= amount; this.dispatchEvent( new CustomEvent("changed", { detail: this.health }) ); } } const bar = new HealthBar(); bar.addEventListener("changed", (event) => console.log("health now", event.detail)); bar.damage(1); bar.damage(2);
signal changed(health: int) var health := 3 func damage(amount: int) -> void: health -= amount changed.emit(health) func _on_changed(new_health: int) -> void: print("health now %d" % new_health) changed.connect(_on_changed) damage(1) damage(2)
The mechanism is the same and the guarantees are not. A DOM event name is a string the browser looks up at run time, so a typo in addEventListener("chnaged", …) is silence. A Godot signal is declared on the class with a name and a parameter list, so changed.connect(…) with the wrong name fails when the script loads, and connecting a handler whose signature does not match fails too. Connections are also first-class: disconnect, is_connected and get_connections() all exist, and the editor shows every connection in its Node dock — there is no equivalent of wondering what is listening to a DOM node.
connectedCallback is _ready
Custom elements are the closest thing the web has to a node with a lifecycle, and the correspondence is unusually direct.
class ScoreBoard extends HTMLElement { connectedCallback() { console.log("attached to the document"); } disconnectedCallback() { console.log("detached"); } } customElements.define("score-board", ScoreBoard); const board = document.createElement("score-board"); document.body.appendChild(board); board.remove();
extends Node func _ready() -> void: print("attached to the tree") func _enter_tree() -> void: print("entering") func _exit_tree() -> void: print("leaving")
The order is _enter_tree, then every child's _ready, then this node's _ready — so by the time _ready runs, everything beneath you is built, which is exactly the guarantee connectedCallback does not give you about a custom element's children. That ordering is why _ready is where you look up other nodes and connect signals, and _init is only for work that needs nothing from the tree. _exit_tree is disconnectedCallback and is where a manual subscription gets undone.
Drawing a User Interface
A button, on both sides, that really works
Both columns put a working button in the pane below and both count their own clicks — go and click them. The JavaScript one is real DOM in a sandboxed frame; the GDScript one is a real Godot engine drawing a real Button node.
const button = document.createElement("button"); button.textContent = "Click me"; button.style.cssText = "padding:10px 22px;font:15px system-ui;border-radius:4px"; let tally = 0; button.addEventListener("click", () => { tally += 1; button.textContent = `Clicked ${tally} time(s)`; }); document.body.appendChild(button);
var button := Button.new() button.text = "Click me" button.position = Vector2(24, 24) button.size = Vector2(200, 44) var tally := 0 button.pressed.connect(func(): tally += 1 button.text = "Clicked %d time(s)" % tally) add_child(button)
Line for line, this is the same program. That is the whole argument of this page in one row: a retained object you hold a reference to and mutate, an event you subscribe to, a parent you attach it to. Where they differ is what they are drawing on — HTML reflows into a document whose text is selectable and reachable by a screen reader, while Godot paints a fixed pixel grid that knows nothing about either, and that difference is the real cost of the canvas. A React developer should notice the direction of the comparison. This row is imperative on both sides; React would instead re-derive the button's label from a piece of state and let a reconciler work out the DOM operations. Godot never does that — there is no reconciler, no virtual tree, and no render pass to be inside. Godot is the vanilla column, permanently.
Containers are flexbox
Neither column positions anything by hand: each hands its children to a layout and lets it decide. This is the closest correspondence on the whole page.
const column = document.createElement("div"); column.style.cssText = "display:flex;flex-direction:column;gap:10px;width:240px;" + "padding:20px 24px;font:15px system-ui"; for (const caption of ["Continue", "New game", "Options", "Quit"]) { const entry = document.createElement("button"); entry.textContent = caption; entry.style.padding = "8px"; column.appendChild(entry); } document.body.appendChild(column);
var column := VBoxContainer.new() column.position = Vector2(24, 20) column.size = Vector2(240, 220) column.add_theme_constant_override("separation", 10) for caption in ["Continue", "New game", "Options", "Quit"]: var entry := Button.new() entry.text = caption column.add_child(entry) add_child(column)
A VBoxContainer is flex-direction: column, and the mapping keeps going: HBoxContainer is row, GridContainer is display: grid, add_theme_constant_override("separation", 10) is gap: 10px, a child's size_flags_horizontal = SIZE_EXPAND_FILL is flex: 1, and a Control's anchors and offsets are position: absolute with inset. It is close enough that a web developer can usually guess the Godot name. The one structural difference: a Godot container is a node in the tree that a script can reach and reconfigure, rather than a property of the parent box.
Turning data into an interface
The same three records, turned into an interface by a loop on both sides. Both render live below.
const players = [ { name: "Ada", score: 1200 }, { name: "Grace", score: 980 }, { name: "Alan", score: 640 }, ]; const table = document.createElement("div"); table.style.cssText = "padding:20px 24px;font:15px system-ui"; for (const player of players) { const row = document.createElement("div"); row.style.display = "flex"; const who = document.createElement("span"); who.style.width = "120px"; who.textContent = player.name; const points = document.createElement("span"); points.textContent = String(player.score); row.append(who, points); table.appendChild(row); } document.body.appendChild(table);
var players := [ { "name": "Ada", "score": 1200 }, { "name": "Grace", "score": 980 }, { "name": "Alan", "score": 640 }, ] var table := VBoxContainer.new() table.position = Vector2(24, 20) table.size = Vector2(280, 160) for player in players: var row := HBoxContainer.new() var who := Label.new() who.text = player["name"] who.custom_minimum_size = Vector2(120, 0) var points := Label.new() points.text = str(player["score"]) row.add_child(who) row.add_child(points) table.add_child(row) add_child(table)
The two columns are the same length and the same shape, which is the honest result: neither vanilla JavaScript nor GDScript has a template language, so both write the loop and call the constructors. This is the row where a React or Svelte developer feels the gap rather than a JavaScript one — players.map(…) returning JSX, or an {#each} block, collapses this to four lines, and Godot has no answer to it in code. Its answer is elsewhere: a repeated interface is authored once in the editor, saved as a .tscn scene file, and instanced at run time with preload("res://row.tscn").instantiate() — a prefab rather than a template, declarative in a file rather than in the language.
canvas.getContext("2d") is _draw()
Below the widget layer, both sides emit drawing commands directly — and this is the one place where the JavaScript column and the Godot column are doing exactly the same kind of thing.
const canvas = document.createElement("canvas"); canvas.width = 300; canvas.height = 200; document.body.appendChild(canvas); const context = canvas.getContext("2d"); context.fillStyle = "#2b2f3a"; context.fillRect(20, 20, 260, 120); context.fillStyle = "#cc342d"; context.beginPath(); context.arc(90, 80, 40, 0, Math.PI * 2); context.fill(); context.strokeStyle = "#478cbf"; context.lineWidth = 4; context.beginPath(); context.moveTo(150, 110); context.lineTo(260, 40); context.stroke(); context.fillStyle = "#478cbf"; context.font = "16px system-ui"; context.fillText("drawn on a canvas", 24, 175);
extends Control func _draw() -> void: draw_rect(Rect2(20, 20, 260, 120), Color("#2b2f3a")) draw_circle(Vector2(90, 80), 40, Color("#cc342d")) draw_line(Vector2(150, 110), Vector2(260, 40), Color("#87c8f5"), 4.0) draw_string(ThemeDB.fallback_font, Vector2(24, 170), "drawn by _draw()", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color("#87c8f5"))
Both are immediate mode: the calls paint and nothing is retained, so changing the circle means running the whole routine again. The difference is who decides when. In JavaScript you call the drawing code yourself, usually from a requestAnimationFrame loop. In Godot you never call _draw — the engine calls it when it decides the node needs repainting, and queue_redraw() is how you tell it that something changed. That is strictly less work and strictly less control, and it is the same bargain _process makes. Note the GDScript column's shape too: no statements at the outer level, just extends and a lifecycle method, which is what a real script file looks like.
requestAnimationFrame is _process(delta)
Both squares below are moving, by the same arithmetic, driven by the same idea. Watch them, then read what is different.
const marker = document.createElement("div"); marker.style.cssText = "position:absolute;top:90px;width:40px;height:40px;background:#cc342d"; document.body.style.height = "200px"; document.body.style.position = "relative"; document.body.appendChild(marker); let previous = performance.now(); let elapsed = 0; function frame(now) { const delta = (now - previous) / 1000; previous = now; elapsed += delta; marker.style.left = `${120 + Math.sin(elapsed * 2) * 90}px`; requestAnimationFrame(frame); } requestAnimationFrame(frame);
extends Control var marker: ColorRect var elapsed := 0.0 func _ready() -> void: marker = ColorRect.new() marker.color = Color("#cc342d") marker.size = Vector2(40, 40) add_child(marker) func _process(delta: float) -> void: elapsed += delta marker.position = Vector2(120.0 + sin(elapsed * 2.0) * 90.0, 90.0)
The JavaScript column has to build the frame loop: capture a timestamp, subtract the previous one, divide by a thousand, and re-register itself. The GDScript column is the frame loop — _process(delta) is called once per frame with the seconds elapsed already computed, and there is nothing to schedule or cancel (set_process(false) turns it off). That is the whole difference: a browser lends you a frame when you ask, an engine hands you one whether you asked or not. Worth knowing that Godot also offers the declarative form, as a Tween or an AnimationPlayer — for a fixed motion between two values those are the CSS-transition-shaped answer, and they run without your code being involved at all.
🚨 There is no cascade
This is the one place on the page where the two models genuinely do not correspond, and it is worth one row to say so plainly rather than several rows pretending otherwise.
// CSS: a stylesheet matches elements by SELECTOR, and // declarations cascade down from ancestors. const style = document.createElement("style"); style.textContent = ` .panel { font: 15px system-ui; color: #2b2f3a; padding: 16px } .panel button { border-radius: 8px; padding: 6px 14px } `; document.head.appendChild(style); const panel = document.createElement("div"); panel.className = "panel"; panel.innerHTML = "<p>Inherited font and color.</p><button>Styled by selector</button>"; document.body.appendChild(panel);
# Godot: a Theme is a RESOURCE looked up by node type. # Nothing matches, and nothing cascades. var rounded := StyleBoxFlat.new() rounded.bg_color = Color("#3a4152") rounded.set_corner_radius_all(8) rounded.set_content_margin_all(8) var theme := Theme.new() theme.set_stylebox("normal", "Button", rounded) var panel := VBoxContainer.new() panel.position = Vector2(24, 20) panel.size = Vector2(280, 160) panel.theme = theme # applies to this subtree var caption := Label.new() caption.text = "A Theme resource, not a stylesheet." panel.add_child(caption) var button := Button.new() button.text = "Styled by node type" panel.add_child(button) add_child(panel)
CSS is a selector language with a cascade: a rule finds elements by matching, and properties like color and font inherit down the tree unless something overrides them. A Godot Theme is a resource — an object holding named styles keyed by node type ("normal" for "Button"), assigned to a node and applying to everything beneath it. Nothing matches, nothing inherits property by property, and there is no specificity to lose an argument with. The nearest CSS relatives are a design-token file and all: revert, and the practical shape of a Godot project reflects it: one Theme resource authored in the editor for the whole game, plus add_theme_*_override on the handful of nodes that need to differ.
Tooling & Shipping
import is preload
A JavaScript developer will look for import first, and there is nothing to find.
// player.js // export class Player { … } // export const MAX_HEALTH = 100; // // main.js // import { Player, MAX_HEALTH } from "./player.js"; // const player = new Player(); // Everything here is module-scoped by default and // resolved by the bundler or the browser. console.log("modules are files, and imports are explicit");
# player.gd # class_name Player # extends RefCounted # const MAX_HEALTH := 100 # # main.gd # var player := Player.new() # class_name is GLOBAL # const Enemy := preload("res://enemy.gd") # or by path print("a file is a class, and class_name skips the import")
There is no import statement. A script file is a class, and there are two ways to reach another one: class_name Player registers the name globally across the whole project, so nothing needs importing; or preload("res://enemy.gd") loads a file by path at parse time and gives you the class as a value. A JavaScript developer should read class_name as deliberately global — it is closer to a browser global than to an ES module export, and the discipline it needs is naming rather than scoping. preload resolves when the script is parsed and load at run time, which is the distinction between a static import and a dynamic import().
npm install vs. dropping a folder in
A JavaScript developer should brace for this one. It is the biggest step down in tooling on the whole page.
// package.json // { "dependencies": { "howler": "^2.2.4" } } // // npm install // import { Howl } from "howler"; console.log("a registry, a lockfile, and a resolver");
# There is no package manager and no lockfile. # An addon is a FOLDER you copy into the project: # # res://addons/dialogic/plugin.cfg # # then enable it in Project Settings > Plugins. print("a folder, a manifest, and a checkbox")
Godot has no package manager. The Asset Library is a website you download a zip from, and installing an addon means copying a folder into res://addons/ and enabling it — there is no version resolution, no lockfile, no transitive dependencies, and no way to say which version you are on except by looking. In practice a Godot project vendors everything and commits it, which is closer to how the web worked before npm than to how it works now. The compensating fact is that the standard library is enormous: physics, audio, navigation, animation, tweening, networking, HTTP, JSON, regular expressions and a UI toolkit are all in the engine, so the number of dependencies a real project needs is much smaller.
TypeScript vs. types built into the language
Both languages bolt static types onto a dynamic core. Only one of them does it inside the language.
// TypeScript is a separate language that compiles away. // interface Player { name: string; health: number } // const player: Player = { name: "Ada", health: 100 }; // tsc --noEmit console.log("a compiler you add, checking a file at a time");
# GDScript's types are part of the language and are # checked when the script LOADS — no build step at all. var health: int = 100 var names: Array[String] = ["Ada"] # Turn the warning into an error for a whole project: # Project Settings > Debug > GDScript > Untyped Declaration print(typeof(health) == TYPE_INT)
The trade is worth stating clearly. TypeScript is more expressive by a wide margin — generics, unions, mapped types, structural typing — and it is a separate toolchain that erases at build time. GDScript's types are nominal, much simpler, and enforced by the runtime that runs your game, with no build step and no configuration. They are also opt-in per variable, which means a codebase can be half-typed; the project setting above is how a team makes untyped declarations a warning or an error, and turning it on is the closest thing to strict mode.
Shipping it
The page you are reading is itself the proof of the target on the right: the GDScript column runs in a Godot exported exactly this way.
// A bundler produces static files; a host serves them. // npm run build → dist/ // deploy dist/ → https://example.com // // One target. The browser is the runtime, and the // user already has it. console.log("one artifact, served over HTTP");
# An export template is a prebuilt engine binary; the # exporter packs your project into it. # # Project > Export > Web / Windows / macOS / iOS / … # godot --headless --export-release "Web" index.html # # Many targets. The engine ships WITH the game. print("one project, many engine binaries")
A web build is one artifact because the runtime is already installed on every machine. A Godot export bundles the engine with the game, once per platform, from a prebuilt "export template" — which is why a desktop build is tens of megabytes before your own content and why the Web export is around ten megabytes compressed. The Web target has the tightest constraints (no threads unless the server sends cross-origin isolation headers, no filesystem beyond a virtual one) and is the one a JavaScript developer will try first; it is worth knowing that it is a real Godot rather than a reduced one.
Gotchas for JavaScript Developers
Indentation is syntax, and tabs are not spaces
The first thing a JavaScript developer gets wrong, usually within an hour.
// Whitespace means nothing. Braces decide everything. function describe(health) { if (health < 50) { return "hurt"; } return "fine"; } console.log(describe(30));
# Indentation decides everything, and a file may not # MIX tabs and spaces — that is a parse error, not a warning. func describe(health: int) -> String: if health < 50: return "hurt" return "fine" print(describe(30))
A Python developer expects this; a JavaScript developer usually does not. Beyond the obvious, two specifics bite: a file that mixes tab and space indentation fails to parse rather than merely looking untidy, and the editor defaults to tabs, so pasting space-indented code from a web page produces a confusing error on a line that looks fine. There is no formatter in the box comparable to Prettier — the editor reindents, and gdformat from the community gdtoolkit package is the nearest equivalent.
The reference that outlives its object
This is the one place where GDScript is meaningfully more dangerous than JavaScript, and it is because the engine underneath is C++.
// There is no way to free this early. As long as any // reference exists, the object does — and once none does, // the collector takes it and no reference can observe that. const temporary = { name: "Temporary" }; const alias = temporary; console.log(alias.name); console.log(alias === temporary);
# A freed Node leaves a reference behind that is no # longer valid — and using it is a crash, not a null. var node := Node.new() node.name = "Temporary" print(is_instance_valid(node)) node.free() print(is_instance_valid(node)) # node.name here would take the game down.
JavaScript has no dangling references: as long as you hold one, the object is alive. Godot's Node and Object are manually freed, so a variable can outlive what it points at, and touching it afterwards is undefined behavior rather than an exception. is_instance_valid(node) is the guard — it is WeakRef::expired() spelled as a function — and queue_free() is the safe way to destroy something, because it waits until the end of the frame rather than pulling the object out from under code that is mid-iteration. The habit worth forming: prefer queue_free(), and null out any long-lived reference you kept.
The method that returns nothing
Both columns sort an array in place. Only one of them hands it back.
const names = ["Grace", "Ada"]; const sorted = names.sort(); // sorts in place AND returns it console.log(sorted); const upper = names.map((n) => n.toUpperCase()); // new array console.log(upper, names);
var names := ["Grace", "Ada"] names.sort() # sorts in place, returns NOTHING print(names) # var sorted := names.sort() would be null. var upper := names.map(func(n): return n.to_upper()) print(upper, " ", names)
JavaScript's mutating array methods return the array, so chaining works and const sorted = names.sort() looks reasonable even though it is a lie about immutability. GDScript's mutating methods return voidsort, reverse, shuffle, append and clear all change the array and hand back nothing — so the same line assigns null and the mistake shows up several lines later. The rule is easy once stated: if a method changes the thing, it returns nothing; if it returns something, it made something new.
The properties that are methods
A small difference with a high frequency: these are methods, not properties.
const text = "Godot"; const list = [1, 2, 3]; console.log(text.length); console.log(list.length); console.log(Object.keys({ a: 1 }).length);
var text := "Godot" var list := [1, 2, 3] print(text.length()) print(list.size()) print({ "a": 1 }.size())
A small thing that costs a JavaScript developer a compile error a day for a week. There are no computed properties on the built-in types: length is a method on String, and on an Array or Dictionary it is called size instead. The mistake is caught when the script loads rather than producing undefined, so it is annoying rather than dangerous — which is a fair summary of most of the differences on this page.
No ++, and no comma operator
Three pieces of C-family syntax that GDScript deliberately does not have.
let count = 0; count++; ++count; console.log(count); for (let i = 0, j = 10; i < j; i++, j--) { /* … */ } console.log("comma operator, three-clause for");
var count := 0 count += 1 count += 1 print(count) # No ++, no --, no comma operator, no three-clause for. for index in range(0, 3): pass print("range() and += cover the ground")
GDScript removed the increment operators deliberately — they were a documented source of confusion between the prefix and postfix forms, and += 1 says the same thing without the question. There is likewise no comma operator and no three-clause for, so a loop with two moving indices keeps one in the header and advances the other in the body. None of this is hard; all of it will be typed wrong a dozen times in the first week.
What is copied and what is shared
Arrays and Dictionaries behave exactly as JavaScript objects do. The built-in math types do not, and that is the surprise.
const first = [1, 2, 3]; const alias = first; // same array const copy = [...first]; // new array alias.push(4); console.log(first, copy); const point = { x: 1 }; const pointAlias = point; pointAlias.x = 99; console.log(point.x);
var first := [1, 2, 3] var alias := first # same array var copied := first.duplicate() alias.append(4) print(first, " ", copied) # But a Vector2 is a VALUE — this copies. var point := Vector2(1, 0) var point_alias := point point_alias.x = 99 print(point.x)
The first half is familiar: an Array and a Dictionary are reference types, assignment aliases them, and duplicate() is the copy (duplicate(true) for a deep one). The second half has no JavaScript equivalent. Vector2, Rect2, Color and Transform2D are value types — assigning one copies it, so point_alias.x = 99 leaves point alone. The practical consequence appears constantly in real code: node.position.x += 1 works, but reading var where := node.position and then mutating where changes nothing, because you are holding a copy.