PONYλM2Modula-2

GDScript.CodeCompared.To/JavaScript

An interactive executable cheatsheet comparing GDScript and JavaScript

GDScript 4.7 (Godot 4.7.2) JavaScript
False Friends — Same Shape, Different Answer
An empty array is true
Start here, because both languages have truthiness and they disagree about the most common case. The two columns ask the same question of the same empty list.
var items := [] if items: print("truthy") else: print("falsy")
const items = []; if (items) { console.log("truthy"); } else { console.log("falsy"); }
In JavaScript every object is truthy, and an array is an object — so if (items) only tells you the variable is not null or undefined. The length check you actually wanted is items.length === 0, and it is the emptiness test you will write constantly.
Two equality operators, and one of them lies
JavaScript's == converts types before comparing, so values of different types can be equal. === is the one that compares without converting.
# GDScript has NO cross-type equality at all. Both of these are # errors rather than false: # "5" == 5 -> Invalid operands 'String' and 'int' # 0 == false -> Invalid operands to operator ==, int and bool # So a comparison across types has to convert first, in the open. var text = "5" var number = 5 print(int(text) == number) print(text == str(number))
console.log("5" == 5); console.log(0 == false); console.log("5" === 5); console.log(0 === false);
GDScript is stricter than either JavaScript operator. Comparing a String to an int is an error, not false — at parse time for literals, and at run time for Variants. JavaScript's == answers true to both of the comparisons in that comment. Write === always; it is the near-universal convention and linters flag == by default.
There is no integer division
Byte-identical arithmetic. GDScript divides two integers as integers; JavaScript has only one number type and always divides as floating point.
print(7 / 2) print(7 % 2) print(10 / 5)
console.log(7 / 2); console.log(7 % 2); console.log(10 / 5);
The floor is Math.floor(7 / 2), and there is no separate integer type to fall back on. Note the last line prints 2 in both — JavaScript displays a whole-valued float without a decimal point, which hides the difference exactly where it would be most reassuring.
Hello World & Output
Hello, World
Both columns run for real — the GDScript in an actual Godot engine, the JavaScript in your browser as you read this.
print("Hello, World!")
console.log("Hello, World!");
There is no global print; output goes through console. The semicolon is optional because JavaScript inserts them automatically, and omitting them is a live style argument rather than a settled one.
Putting a value in a message
GDScript formats with % and an array. JavaScript uses a template literal — backticks rather than quotes, with ${…} around each expression.
var player_name := "Robi" var score := 1200 print("%s scored %d" % [player_name, score])
const playerName = "Robi"; const score = 1200; console.log(`${playerName} scored ${score}`);
Any expression fits inside the braces, and the backticks also allow real newlines inside the string, which ordinary quotes do not. This is the same idea as Swift's \(…) and Python's f-string, with a third spelling.
Variables & Scope
let, const, and the var you should not use
let is the reassignable one and const is the default most code reaches for. The older var has different scoping rules and is effectively deprecated.
var health := 100 health -= 30 print(health) const MAX := 100 print(MAX)
let health = 100; health -= 30; console.log(health); const MAX = 100; console.log(MAX); const stats = { level: 1 }; stats.level = 2; console.log(stats.level);
🚨 const prevents reassignment, not mutation — the last three lines change a property of a const object and that is legal. GDScript's const is closer to genuinely constant, so this one catches people.
There are two kinds of nothing
GDScript has one null. JavaScript has null, meaning "deliberately nothing", and undefined, meaning "never given a value" — and a missing property gives you the second.
var target = null print(target == null) var stats := {"level": 1} print(stats.get("missing"))
let target = null; console.log(target === null); let never; console.log(never === undefined); const stats = { level: 1 }; console.log(stats.missing); console.log(target == undefined);
They are ===-different and ==-equal, which is one of the few places == is genuinely useful: x == null is the idiomatic "either kind of nothing" check. Reading a missing property is not an error, which is why typos survive to runtime.
Strings
Length, case, slicing and searching
These line up almost exactly. length is a property rather than a method, so it takes no parentheses — the same distinction the C# and Swift pages make.
var title := "Crystal Cavern" print(title.length()) print(title.to_upper()) print(title.substr(0, 7)) print(title.contains("Cave")) print(title.split(" ").size())
const title = "Crystal Cavern"; console.log(title.length); console.log(title.toUpperCase()); console.log(title.slice(0, 7)); console.log(title.includes("Cave")); console.log(title.split(" ").length);
slice takes start and end rather than start and count, so slice(0, 7) and substr(0, 7) agree here by coincidence. slice(-6) counts from the end, which GDScript's substr cannot do.
One Number Type
Every number is a float
JavaScript has one number type and it is a 64-bit float. typeof 3 is "number", the same as typeof 3.5 — there is no integer type to ask about.
var count := 3 print(count) print(typeof(count) == TYPE_INT) print(0.1 + 0.2)
const count = 3; console.log(count); console.log(typeof count); console.log(0.1 + 0.2); console.log(Number.isInteger(count));
So integer arithmetic is exact only up to 2^53, and 0.1 + 0.2 shows the same floating-point result both columns print. Number.isInteger asks whether a value happens to have no fractional part, which is a different question from what type it is.
The math you use every frame
GDScript puts these in the global scope; JavaScript groups them on Math. The names match apart from one absence.
print(abs(-7)) print(min(3, 9)) print(sqrt(16.0)) print(floor(2.6)) print(clamp(15, 0, 10))
console.log(Math.abs(-7)); console.log(Math.min(3, 9)); console.log(Math.sqrt(16)); console.log(Math.floor(2.6)); console.log(Math.min(10, Math.max(0, 15)));
There is no Math.clamp, so you nest min and max — the same gap Lua has. Math.random() returns a float in [0, 1) and cannot be seeded, which is worth knowing before porting anything that relies on a reproducible sequence.
Arrays & Objects
The everyday array
Very close: same literal syntax, same zero-based indexing, and push for append. Negative indexing needs at(-1) rather than bare brackets.
var loot := ["sword", "shield", "potion"] loot.append("rope") print(loot.size()) print(loot[0]) print(loot[-1]) print(loot.has("shield"))
const loot = ["sword", "shield", "potion"]; loot.push("rope"); console.log(loot.length); console.log(loot[0]); console.log(loot.at(-1)); console.log(loot.includes("shield"));
loot[-1] is not an error — it reads a property named "-1" and gives undefined, because a JavaScript array is an object with numeric-looking keys. That is the single most useful thing to understand about arrays here.
Objects, and the Map you probably want
An object literal is the closest thing to a Dictionary, and its keys are strings whether you quote them or not. Map is the purpose-built one, with a real size and keys of any type.
var stats := {"strength": 12, "agility": 8} stats["luck"] = 3 print(stats["strength"]) print(stats.has("agility")) print(stats.size())
const stats = { strength: 12, agility: 8 }; stats.luck = 3; console.log(stats.strength); console.log("agility" in stats); console.log(Object.keys(stats).length); const typed = new Map([["strength", 12]]); typed.set("luck", 3); console.log(typed.get("strength"), typed.size, typed.has("luck"));
Reach for Map when keys are data rather than field names: an object turns every key into a string, so obj[1] and obj["1"] are the same entry, while a Map keeps them apart. GDScript dictionaries behave like Map, not like an object.
Pulling values out by shape
Destructuring pulls several values out in one statement, matching by property name for objects and by position for arrays. GDScript has no equivalent for either.
var enemy := {"name": "bat", "hp": 12} var name = enemy["name"] var hp = enemy["hp"] print("%s %d" % [name, hp]) var pair := [3, 4] var x = pair[0] var y = pair[1] print("%d,%d" % [x, y])
const enemy = { name: "bat", hp: 12 }; const { name, hp } = enemy; console.log(`${name} ${hp}`); const pair = [3, 4]; const [x, y] = pair; console.log(`${x},${y}`); const { name: label, missing = "none" } = enemy; console.log(label, missing);
The last line shows the two extras worth knowing: name: label renames on the way out, and missing = "none" supplies a default when the property is absent. This shape appears in nearly every modern JavaScript function signature.
Control Flow
match becomes switch, and it falls through
JavaScript's switch does work on strings, unlike C++'s. What it does not do is end a branch for you.
var state := "walking" match state: "idle": print("standing still") "walking", "running": print("moving") _: print("unknown")
const state = "walking"; switch (state) { case "idle": console.log("standing still"); break; case "walking": case "running": console.log("moving"); break; default: console.log("unknown"); }
🚨 A forgotten break falls into the next case and runs it too, and it is not an error — unlike C#, which refuses to compile. Stacking two labels, as here, is the deliberate use of the same mechanism.
Falling back, without falling back too eagerly
JavaScript has two fallback operators. || falls back on any falsy value; ?? falls back only on null or undefined.
var supplied = null var name = supplied if supplied != null else "anonymous" print(name) var volume = 0 var level = volume if volume != null else 5 print(level)
const supplied = null; console.log(supplied ?? "anonymous"); const volume = 0; console.log(volume || 5); console.log(volume ?? 5);
That distinction matters exactly when the value is 0 — a volume of zero is a real setting, and volume || 5 silently replaces it with 5 while volume ?? 5 keeps it. This is one of the most common real bugs in JavaScript settings code.
Loops & Iteration
Walking and counting
for … of is GDScript's for x in collection. There is no range(), so counting uses the three-clause C-style loop.
var party := ["Ari", "Bex", "Cyd"] for member in party: print(member) for i in range(3): print(i)
const party = ["Ari", "Bex", "Cyd"]; for (const member of party) { console.log(member); } for (let i = 0; i < 3; i++) { console.log(i); }
🚨 for … in is a different loop that iterates keys, so using it on an array gives you the strings "0", "1", "2". One letter apart, and it is the classic JavaScript mistake.
Filtering and transforming
The accumulate-into-an-empty-array loop is one chained line. The arrow function is the compact closure form, and with one expression the return is implicit.
var scores := [42, 91, 7, 68, 15] var high := [] for score in scores: if score > 40: high.append(score * 10) print(high)
const scores = [42, 91, 7, 68, 15]; const high = scores.filter(score => score > 40).map(score => score * 10); console.log(high); console.log(scores.reduce((total, score) => total + score, 0)); console.log(scores.some(score => score > 90));
GDScript has map and filter taking lambdas, so the idea is familiar. reduce, some, every and find round out the set, and chaining them is the default shape of JavaScript data code.
Needing the index too
entries() yields index-and-value pairs, and destructuring names them in place — the two features from earlier sections meeting in one line.
var waves := ["bats", "slimes", "boss"] for i in range(waves.size()): print("wave %d: %s" % [i + 1, waves[i]])
const waves = ["bats", "slimes", "boss"]; for (const [index, wave] of waves.entries()) { console.log(`wave ${index + 1}: ${wave}`); }
forEach((wave, index) => …) does the same job with a callback. The of version is usually preferred because break and await work inside it, and neither works inside a forEach.
Functions, Closures & this
Declaring a function
No types anywhere. Both forms shown are ordinary: function declarations are hoisted, so they can be called before the line that defines them, while an arrow function assigned to a const cannot.
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))
function damageAfterArmor(damage, armor) { return Math.max(damage - armor, 0); } const armored = (damage, armor) => Math.max(damage - armor, 0); console.log(damageAfterArmor(30, 12)); console.log(armored(5, 12));
Calling with too few arguments is not an error — the missing ones are undefined, and the arithmetic quietly produces NaN. That is the cost of the flexibility, and it is why TypeScript exists.
this means whatever the call site decided
This is the difference with no GDScript counterpart at all. Taking a method out of its object detaches it: this is decided by how a function is called, not by where it was written.
class Counter: var total := 0 func bump() -> void: total += 1 var counter := Counter.new() var handler := counter.bump handler.call() print(counter.total)
class Counter { constructor() { this.total = 0; } bump() { this.total += 1; } bumpArrow = () => { this.total += 1; }; } const counter = new Counter(); const loose = counter.bump; try { loose(); } catch (error) { console.log("lost this:", error.constructor.name); } const bound = counter.bumpArrow; bound(); console.log(counter.total);
The GDScript column just works, because a Callable remembers its object. In JavaScript the fixes are counter.bump.bind(counter), a wrapping arrow function, or — as here — a class field holding an arrow function, which captures this at construction. Every callback you pass to an event handler meets this.
Classes & Prototypes
Declaring a class
The shape is close: constructor for _init, and new to instantiate. Fields are created by assigning to this, and every method body must say this. explicitly.
class Potion: var strength: int func _init(value: int) -> void: strength = value func describe() -> String: return "potion of %d" % strength var small := Potion.new(5) print(small.describe())
class Potion { #secret = "brewed"; constructor(strength) { this.strength = strength; } describe() { return `potion of ${this.strength}`; } } const small = new Potion(5); console.log(small.describe());
A name beginning with # is genuinely private and unreachable from outside — the only real privacy JavaScript has. Underneath, class is syntax over prototypes: Object.getPrototypeOf(small) === Potion.prototype.
Inheritance
extends is spelled the same and means the same thing. Overriding is silent in both — no keyword marks it, and a misspelled name quietly adds a new method rather than replacing one.
class Enemy: func speak() -> String: return "..." class Bat extends Enemy: func speak() -> String: return "screech" var creature: Enemy = Bat.new() print(creature.speak())
class Enemy { speak() { return "..."; } } class Bat extends Enemy { speak() { return "screech"; } } const creature = new Bat(); console.log(creature.speak()); console.log(creature instanceof Enemy);
A subclass constructor must call super(...) before touching this, which is enforced. This is the section where the two languages are most alike, which is worth saying after a page of differences.
One Event Loop
await is the same word for a different machine
GDScript awaits a signal. JavaScript awaits a Promise — an object representing a value that will arrive, which every async function returns whether you meant it to or not.
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")
function wait(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); } async function countdown(label) { await wait(10); console.log(`done: ${label}`); } (async () => { await countdown("wave 1"); console.log("after"); })();
The wrapping (async () => { … })() exists because top-level await needs an ES module, and this runs as a plain script. Forgetting an await gives you the Promise itself rather than the value, which prints as Promise { <pending> } and is the most common async mistake.
Nothing calls you every frame
The engine calls _process every frame, forever. Nothing does that for you in JavaScript — requestAnimationFrame schedules one callback, and a loop exists only because each call asks for the next.
extends Node2D func _process(delta: float) -> void: position.x += 200.0 * delta
let x = 0; let previous = 0; let frames = 0; function step(timestamp) { const delta = previous === 0 ? 0 : (timestamp - previous) / 1000; previous = timestamp; x += 200 * delta; frames += 1; if (frames < 3) requestAnimationFrame(step); else console.log("ran", frames, "frames"); } if (typeof requestAnimationFrame === "function") { requestAnimationFrame(step); } else { console.log("no animation frames outside a browser"); }
It also hands you a timestamp rather than a delta, so the subtraction is yours. This row is why the browser and the engine feel different to write for: in one the loop is given, in the other it is something you construct and can stop.
Errors You Can Finally Throw
GDScript cannot raise; JavaScript can
GDScript has no exceptions — 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))
class BankError extends Error {} function withdraw(balance, amount) { if (amount > balance) { throw new BankError("insufficient funds"); } return balance - amount; } console.log(withdraw(100, 30)); try { console.log(withdraw(100, 500)); } catch (error) { console.log("caught:", error.message); } finally { console.log("done"); }
Look at what the GDScript column is forced to do: return the unchanged balance, indistinguishable from a withdrawal of zero. 🚨 One JavaScript-specific trap: catch receives whatever was thrown, and anything can be thrown — so a robust handler checks error instanceof Error rather than assuming a .message.
The Web Export Seam
Calling JavaScript from a web export
This is the concrete reason a Godot developer reads JavaScript. Verified against Godot 4.7's own documentation: JavaScriptBridge.eval() runs JavaScript and converts the result to a Godot type, and get_interface() fetches a global object such as window.
extends Node func _ready() -> void: # Only in a web export: JavaScriptBridge is not present elsewhere. var doubled = JavaScriptBridge.eval("2 + 2") print(doubled) var window = JavaScriptBridge.get_interface("window") print(window != null)
// The other side of the same seam: this is ordinary page JavaScript, // which the exported game can reach and which can reach back. globalThis.gameSettings = { difficulty: "hard" }; function describeSettings() { return `difficulty is ${globalThis.gameSettings.difficulty}`; } console.log(describeSettings());
create_object() instantiates via JavaScript's new, and download_buffer() hands the visitor a generated file. 🚨 The singleton exists only in a web export, and templates can be built without it for security — so guard on its presence rather than assuming it.
Letting JavaScript call back into the game
A callback goes the other way: JavaScript invokes a Godot function. The shape is fixed by the bridge — the handler must take exactly one Array argument, which is the JavaScript arguments object converted to an array.
extends Node var callback func _ready() -> void: # The callback must take EXACTLY ONE Array argument — the JavaScript # arguments object, converted. Keep a reference or it is collected. callback = JavaScriptBridge.create_callback(_on_web_event) var window = JavaScriptBridge.get_interface("window") window.addEventListener("resize", callback) func _on_web_event(args: Array) -> void: print("the page said: %s" % str(args))
// From the page's side it is an ordinary listener, and the game's // handler is an ordinary function value. function onResize(...args) { console.log("the page said:", args.length, "argument(s)"); } globalThis.addEventListener?.("resize", onResize); onResize(1200, 800);
🚨 Two things bite here. The callback object must be kept in a variable or it is garbage collected and the listener silently stops firing — hence the callback field rather than a local. And it is invoked asynchronously, so it cannot return a value to the JavaScript that called it.
Drawing It, Side by Side
A wave, drawn and printed
The GDScript column below runs in a real Godot engine and draws the wave into the canvas. The JavaScript column runs too, and prints one — because a bare script has no canvas until a page gives it one.
extends Control func _ready() -> void: queue_redraw() func _draw() -> void: var pane := get_viewport_rect().size var points: PackedVector2Array = [] var tints: PackedColorArray = [] var steps := 140 for i in steps + 1: var portion := float(i) / steps var height := sin(portion * TAU * 2.0) * pane.y * 0.28 points.append(Vector2(portion * pane.x, pane.y / 2.0 + height)) tints.append(Color.from_hsv(0.55 + portion * 0.25, 0.6, 0.98)) draw_polyline_colors(points, tints, 2.5, true)
const steps = 24; const rows = 11; const grid = Array.from({ length: rows }, () => Array(steps).fill(" ")); for (let i = 0; i < steps; i++) { const portion = i / steps; const height = Math.sin(portion * Math.PI * 4); const row = Math.round((1 - height) * (rows - 1) / 2); grid[row][i] = "*"; } console.log(grid.map(row => row.join("")).join("\n"));
Both walk the same sine over the same range; only the output device differs. In a browser the JavaScript column would reach for a <canvas> and its 2D context — and a Godot web export is a canvas on that same page, which is exactly what the Web Export Seam section is about.