PONYλM2Modula-2

Lua.CodeCompared.To/GDScript

An interactive executable cheatsheet comparing Lua and GDScript

Lua 5.3 GDScript 4.5 (Godot 4.5.2)
Output & Running It
Hello, World
Identical, and worth showing precisely because it is. Both languages are read by an interpreter embedded in an engine, so neither has a build step, and both spell the output function the same way.
print("Hello, World!")
print("Hello, World!")
Godot also offers print_rich for BBCode markup and printerr for the error stream. The one to avoid out of habit is prints, which joins its arguments with spaces — it looks like a typo for print and is not.
Indentation replaces do/end
This is the first thing a Lua programmer has to unlearn. Lua closes every block with end and does not care how you indent; GDScript takes its structure from indentation the way Python does, and the colon opens the block.
local ready = true if ready then print("blocks are delimited by keywords") print("indentation is decorative") end
var ready := true if ready: print("blocks are delimited by INDENTATION") print("getting this wrong is a parse error")
Godot's editor uses tabs by default and mixing them with spaces is a parse error, not a style complaint. There is no end to hunt for when a block closes in the wrong place — the indentation is the only signal.
Comments
-- A single-line comment. --[[ A long comment spanning several lines. ]] print("commented")
# A single-line comment. # GDScript has no block comment; # each line needs its own hash. print("commented")
There is no multi-line comment at all — the editor's comment-selection shortcut exists because commenting a region means prefixing every line. A triple-quoted string is sometimes used as a stand-in, but it is a real string that gets evaluated and discarded.
A script is attached to a node
In LÖVE a Lua file is just a module — it returns a value and something else decides what to do with it. In Godot the file itself is a class that extends a Node type, and it is attached to a node in a scene tree.
-- In LÖVE a .lua file is loaded by require and returns -- whatever you want; nothing owns it. local player = { x = 0, y = 0 } function player.move(dx) player.x = player.x + dx end player.move(5) print(player.x)
# A .gd file IS a class, and it is attached to a Node in a scene. # The first line declares what it extends; the file needs no return. extends Node var x := 0 var y := 0 func move(dx: int) -> void: x += dx
That is why every GDScript file starts with extends and why its variables are the node's state rather than a table you built. The right column cannot run on this page for exactly that reason: there is no scene tree here, and the runner evaluates snippets at method scope.
Dynamic Lua vs. Optional Static Types
Types are optional but recommended
Lua has one declaration keyword and no type annotations anywhere. GDScript keeps the dynamic default but lets you opt into static types, and the community strongly recommends doing so.
local health = 100 local name = "player" local ratio = 0.5 print(health, name, ratio)
var health := 100 # inferred as int var player_name: String = "player" # declared explicitly var ratio := 0.5 # inferred as float print(health, player_name, ratio)
The := form infers the type and locks it in; the : String = form states it. Both are checked at compile time and both let Godot generate faster code, which is the practical reason to bother — an untyped GDScript variable is a Variant and every operation on it goes through a slower path.
A typed variable cannot change type
An untyped GDScript variable behaves exactly like a Lua local: it holds anything and can be rebound to a different kind of value. Adding a type takes that away.
local value = 42 print(type(value)) value = "now a string" print(type(value))
var loose = 42 # untyped: a Variant, like Lua print(typeof(loose)) loose = "now a string" # allowed print(typeof(loose)) var strict := 42 # typed as int # strict = "nope" # <- would not compile
typeof returns a numeric constant rather than a name, so the printed values are Godot's TYPE_INT and TYPE_STRING enum members. That is a small but real difference from Lua's type, which returns a readable string.
const is real here
Lua has no way to declare a value unchangeable — a capitalized local and a team convention is the whole mechanism. GDScript has const, enforced at compile time.
-- Lua has no constants; the convention is a name in capitals -- and an agreement not to reassign it. local MAX_PLAYERS = 4 print(MAX_PLAYERS)
const MAX_PLAYERS := 4 print(MAX_PLAYERS) # MAX_PLAYERS = 8 # <- would not compile
A const must be computable at compile time, so it cannot hold anything built at run time. Godot also has enum for a named set of integers, which Lua approximates with a table of numbers and no checking.
You cannot create a variable by assigning to it
Lua's global-by-default rule is its most notorious footgun: a forgotten local silently creates a global that every other file can see. GDScript simply does not allow it.
local function set() accidental = "global" -- no 'local': creates a GLOBAL end set() print(accidental)
# Assigning to an undeclared name does not compile in GDScript: # accidental = "global" # -> Identifier "accidental" not declared in the current scope. var declared := "must be declared first" print(declared)
Every name must be declared before use, so the typo becomes a compile error rather than a mysterious shared variable. Godot's equivalent of a deliberate global is an autoload singleton, which is registered in the project settings rather than created by assignment.
No multiple assignment and no swap
Lua evaluates the whole right-hand side before assigning any of it, which is what makes its one-line swap work. GDScript assigns one variable per statement.
local first, second = "a", "b" print(first, second) first, second = second, first print(first, second)
var first := "a" var second := "b" print(first, second) var temporary := first # the swap needs a third name first = second second = temporary print(first, second)
GDScript also has no multiple return values, so this limitation shows up again in the Functions section — a function that wants to hand back two things returns an array or a dictionary.
null, Truthiness, and the Zero Trap
🚨 0 and the empty string are falsy
The single most important row on the page. Lua has exactly two falsy values; GDScript follows the Python-ish convention where emptiness is falsy — 0, "", an empty array and an empty dictionary all fail a truth test.
-- In Lua ONLY nil and false are falsy. if 0 then print("0 is truthy in Lua") end if "" then print("the empty string is truthy too") end
# In GDScript 0, "", [] and {} are all FALSY. if 0: print("never printed") else: print("0 is FALSY in GDScript") if not "": print("the empty string is falsy too")
So if health then in Lua is true for a health of zero — a dead player passes the check — and the same shape in GDScript correctly reports empty. Any guard ported between the two changes meaning silently, and a zero health or an empty inventory is exactly where it bites.
nil becomes null
The absent value is spelled null rather than nil, and it behaves similarly — except when reading a missing key, which Lua answers with nil and GDScript treats as an error.
local target = nil if target == nil then print("no target") end local inventory = { sword = 1 } print(inventory.shield) -- nil: missing keys are fine
var target = null if target == null: print("no target") var inventory := { "sword": 1 } # inventory["shield"] # <- runtime error: invalid index print(inventory.get("shield")) # null, the safe lookup
Indexing a dictionary with a key it does not have raises at run time. Use get(key), which returns null, or get(key, default), or test with has(key) first. This is a genuine source of crashes for anyone carrying Lua habits over.
Assigning null does not delete
In Lua, assigning nil to a table key is the only way to delete it. In GDScript that stores a null value and leaves the key in place.
local inventory = { sword = 1, shield = 2 } inventory.shield = nil -- assigning nil REMOVES the key local count = 0 for _ in pairs(inventory) do count = count + 1 end print(count)
var inventory := { "sword": 1, "shield": 2 } inventory["shield"] = null # the key REMAINS, holding null print(inventory.size()) inventory.erase("shield") # this removes it print(inventory.size())
erase is the removal operation, and it returns whether the key was actually there. Carrying the Lua habit over produces a dictionary that keeps growing and whose size() never goes down.
The and/or idiom does not survive
Lua's or fallback works precisely because 0 is truthy, so a configured value of zero survives. GDScript's or would discard it, because zero is falsy there.
local configured = 0 local speed = configured or 10 print(speed) -- 0: only nil/false trigger the fallback
var configured := 0 var speed = configured if configured != 0 else 10 print(speed) # 10 if you want the fallback -- but be explicit var missing = null var fallback = missing if missing != null else 10 print(fallback)
GDScript's conditional expression is written value if condition else other, borrowed from Python. Spelling the test out — != null rather than relying on truthiness — is the habit that keeps a legitimate zero from being replaced by a default.
Numbers
🚨 Division of two integers truncates
Lua 5.3 gave division two operators so the result type is visible in the source. GDScript has one / whose meaning depends on its operands, exactly like C.
print(7 / 2) -- 3.5: / always produces a float in Lua print(7 // 2) -- 3: floor division is a separate operator
print(7 / 2) # 3 -- INTEGER division, because both are ints print(7.0 / 2) # 3.5 print(float(7) / 2) # 3.5, the usual fix
This is the most common numeric bug when moving from Lua. 7 / 2 is 3, with no warning, and it appears most often in halving a screen dimension or averaging two positions. Make one side a float.
int and float are separate types
Lua 5.3 split numbers into integer and float subtypes, so the distinction is already familiar. GDScript makes them separate types outright, and a typed variable commits to one.
print(math.type(7)) print(math.type(7.0)) print(7 == 7.0) -- true
print(typeof(7) == TYPE_INT) print(typeof(7.0) == TYPE_FLOAT) print(7 == 7.0) # true: compared numerically
A var count: int assigned a float truncates rather than erroring, which is worth knowing when a computed position feeds an index. Godot's float is 64-bit in GDScript but many engine APIs store 32-bit, so round-tripping through a Vector loses precision.
Math functions are global, not in a table
Lua namespaces its math functions in the math table. GDScript puts them in the global scope, so there is no prefix at all.
print(math.floor(3.7), math.max(1, 9), math.abs(-4)) print(math.min(3, 5)) print(string.format("%.2f", math.sqrt(2)))
print(floor(3.7), max(1, 9), abs(-4)) print(min(3, 5)) print("%.2f" % sqrt(2))
Godot adds the ones a game needs and Lua lacks: clamp, lerp, move_toward, deg_to_rad, snapped, and randi_range. Note also the % operator on a string, which is GDScript's string.format.
Strings
Concatenation and formatting
Lua concatenates with ..; GDScript overloads +. The formatting operator is where GDScript is genuinely more compact.
local name = "Ada" print("Hello, " .. name .. "!") print(string.format("%s scored %d", name, 42))
var player_name := "Ada" print("Hello, " + player_name + "!") print("%s scored %d" % [player_name, 42])
The % operator takes a single value or an array of them and uses the same printf-style placeholders Lua's string.format does. Godot also has format with named placeholders, which Lua has no equivalent of.
Length and slicing
Both store the length. The indexing differs twice over: GDScript counts from 0, and its substr takes a start and a length rather than a start and an end.
local text = "hello" print(#text) print(text:sub(1, 1)) -- "h": 1-based, inclusive print(text:sub(2, 3)) -- "el"
var text := "hello" print(text.length()) print(text[0]) # "h": 0-based print(text.substr(1, 2)) # "el": start, then COUNT
So Lua's sub(2, 3) — characters two through three — becomes substr(1, 2): start at index one, take two characters. Translating positions and endpoints in one step is where off-by-ones creep in.
split and join are built in
Lua's standard library has no split, and every Lua project eventually writes the gmatch loop on the left. GDScript ships both halves.
-- Lua has no split; you write it with gmatch. local parts = {} for piece in ("a,b,c"):gmatch("[^,]+") do parts[#parts + 1] = piece end print(#parts, table.concat(parts, "|"))
var parts := "a,b,c".split(",") print(parts.size(), "|".join(parts))
Note the receiver on join: the separator is the string and the array is the argument, which is the opposite of Lua's table.concat(parts, "|") and easy to write backwards.
Lua patterns become RegEx objects
Lua patterns are a small non-backtracking subset built into the string type. GDScript has real regular expressions, but they live in a RegEx object that must be constructed and compiled first.
local sentence = "one two three" for word in sentence:gmatch("%a+") do io.write(word, ";") end print()
var sentence := "one two three" var expression := RegEx.new() expression.compile("[a-z]+") var found := "" for match_result in expression.search_all(sentence): found += match_result.get_string() + ";" print(found)
That ceremony is why simple work usually reaches for split, begins_with or contains instead. Compiling in a loop is a real performance mistake — build the RegEx once and keep it.
Tables Become Arrays and Dictionaries
🚨 One table type becomes two
The single Lua table doing array and hash duty at once is the deepest structural difference here. GDScript has Array and Dictionary as distinct types, and there is no mixing them in one value.
-- ONE type does both jobs, and can do them at once. local mixed = { 10, 20, 30, name = "Ada" } print(#mixed, mixed[1], mixed.name)
# Two separate types with separate literal syntax. var list := [10, 20, 30] var record := { "name": "Ada" } print(list.size(), list[0], record["name"])
That means the common Lua entity table — array part for children, named fields for properties — has to be split in two, or become a class. It is usually the largest mechanical change in porting a LÖVE codebase.
Arrays start at 0
Lua is one of the few languages indexing from 1. GDScript indexes from 0 and iterates a count with range, which produces 0 up to but not including its argument.
local values = { "first", "second", "third" } print(values[1]) for index = 1, #values do io.write(index, "=", values[index], " ") end print()
var values := ["first", "second", "third"] print(values[0]) for index in range(values.size()): print(index, "=", values[index])
Unlike Lua, reading past the end is an error rather than nil, so an off-by-one crashes instead of quietly producing an empty value. That is friendlier in the long run and startling at first.
ipairs and pairs become one for..in
Lua's two iterators become one for … in whose meaning depends on what it is walking: over an array it yields values, over a dictionary it yields keys.
local values = { "a", "b" } for index, value in ipairs(values) do print(index, value) end local record = { x = 1, y = 2 } for key, value in pairs(record) do print(key, value) end
var values := ["a", "b"] for value in values: # the VALUE, not an index/value pair print(value) var record := { "x": 1, "y": 2 } for key in record: # the KEY print(key, record[key])
There is no built-in equivalent of ipairs giving you the index alongside the value — use range(array.size()) when you need it. Dictionary iteration order is insertion order and is guaranteed, unlike Lua's pairs, which is explicitly unspecified.
Arrays have methods
Lua puts these in the table library as free functions. GDScript makes them methods on the array, which reads better and is harder to misapply.
local values = { "b", "a" } table.insert(values, "c") table.sort(values) print(table.concat(values, ",")) table.remove(values, 1) print(table.concat(values, ","))
var values := ["b", "a"] values.append("c") values.sort() print(",".join(values)) values.remove_at(0) print(",".join(values))
Godot ships considerably more of them than Lua does — filter, map, reduce, any, all, find, pick_random, shuffle — where Lua leaves you to write the loop.
Typed arrays, which Lua has no notion of
A Lua table is heterogeneous by nature. GDScript arrays can be constrained to a single element type, and the constraint is enforced.
-- A Lua table holds anything, in any mixture. local scores = { 10, "twenty", true } print(#scores, type(scores[2]))
var scores: Array[int] = [10, 20, 30] print(scores.size(), typeof(scores[1]) == TYPE_INT) # scores.append("forty") # <- runtime error: wrong type
A typed array rejects a wrong-typed append at run time and lets Godot store the elements more compactly. Godot also ships packed arrays — PackedInt32Array, PackedVector2Array — which are contiguous native memory and matter for mesh and particle data.
Arrays and dictionaries are references
A convergence: both languages pass collections by reference, so assigning one to a new name gives two names for one object.
local original = { 1, 2, 3 } local alias = original alias[1] = 99 print(original[1]) -- 99: the same table local copy = { table.unpack(original) } copy[1] = 1 print(original[1], copy[1])
var original := [1, 2, 3] var alias := original alias[0] = 99 print(original[0]) # 99: the same array var copy := original.duplicate() copy[0] = 1 print(original[0], copy[0])
duplicate() is the shallow copy, matching Lua's table.unpack idiom, and duplicate(true) is a deep copy — which Lua has no built-in equivalent of at all. Note that Godot's built-in value types such as Vector2 are copied, not shared, which is the opposite of what a Lua table holding {x, y} would do.
Control Flow, and a Real match
elseif becomes elif
Structurally identical; the keyword contracts to elif, the parentheses stay off, and the block is opened with a colon and closed by dedenting.
local score = 72 if score >= 90 then print("A") elseif score >= 70 then print("B") else print("C") end
var score := 72 if score >= 90: print("A") elif score >= 70: print("B") else: print("C")
Remember the truthiness section applies to the condition itself: a bare if count: is false for zero here and true in Lua, so the port needs reading rather than translating.
match, which Lua has no equivalent of
Lua has no switch, so dispatch is an if chain or a table of functions. GDScript has match, and it is considerably more than a switch.
local command = "stop" if command == "go" then print("moving") elseif command == "stop" then print("halted") else print("unknown") end
var command := "stop" match command: "go": print("moving") "stop": print("halted") _: print("unknown")
It matches on value, on type, on array shape with a binding ([x, y]), and on dictionary shape, with _ as the catch-all. There is no fall-through between arms, so no break is needed. This is one of the genuinely nicer things waiting on the Godot side.
The numeric for becomes range
Lua's numeric for takes an inclusive limit. range takes an exclusive one, so every bound shifts by one when translating.
for index = 1, 5 do io.write(index, " ") end print() for index = 10, 1, -3 do io.write(index, " ") end print()
var line := "" for index in range(1, 6): # end is EXCLUSIVE line += str(index) + " " print(line) line = "" for index in range(10, 0, -3): line += str(index) + " " print(line)
A single argument, range(5), counts 0 through 4 — which pairs with zero-based indexing and is the form you will write most. The three-argument form takes a step, negative included, exactly as Lua's third expression does.
while, break and a real continue
The while loops match. The difference is continue: Lua has none and fakes it with a goto to a label at the end of the body, which is the idiom in the left column.
local countdown = 3 while countdown > 0 do io.write(countdown, " ") countdown = countdown - 1 end print() for index = 1, 5 do if index % 2 == 0 then goto continue end io.write(index, " ") ::continue:: end print()
var countdown := 3 var line := "" while countdown > 0: line += str(countdown) + " " countdown -= 1 print(line) line = "" for index in range(1, 6): if index % 2 == 0: continue # a real keyword line += str(index) + " " print(line)
GDScript has the keyword outright and also has compound assignment (countdown -= 1), which Lua lacks entirely — every Lua increment must be written x = x + 1.
There is no repeat/until
Lua's bottom-tested loop has no counterpart in GDScript, which has only while and for.
local attempts = 0 repeat attempts = attempts + 1 until attempts >= 3 print(attempts)
var attempts := 0 while true: attempts += 1 if attempts >= 3: break print(attempts)
The translation is a while true with the test moved to the bottom as a break. Note the condition keeps Lua's sense — it stops when the condition becomes true — rather than being inverted the way a do…while translation would require.
Functions & Lambdas
func, with typed parameters
Both declare functions with a keyword, and both allow untyped parameters. GDScript adds optional parameter types and a return type after the arrow.
local function add(left, right) return left + right end print(add(2, 3))
func add(left: int, right: int) -> int: return left + right # Calling it: print(add(2, 3))
A named func must live at class scope, which is why this column cannot run on this page — both runners evaluate a snippet inside a method body. The lambda in the next row is the form that does work there.
Anonymous functions
Lua functions are values and calling one stored in a variable needs no ceremony. GDScript 4 added lambdas, and they are values too — but they are Callable objects, and invoking one takes .call().
local double = function(value) return value * 2 end print(double(21)) local numbers = { 1, 2, 3 } local total = 0 for _, value in ipairs(numbers) do total = total + double(value) end print(total)
var double := func(value): return value * 2 print(double.call(21)) var numbers := [1, 2, 3] var doubled := numbers.map(double) print(doubled.reduce(func(running, value): return running + value, 0))
That .call() is easy to forget and the error is not obvious. The payoff is that a Callable plugs straight into map, filter, reduce and sort_custom, and into signal connections — none of which Lua ships.
One return value, so arrays or dictionaries
Genuine multiple return values are a Lua feature GDScript does not have. The replacement is an array when the order is obvious or a dictionary when the names matter.
local function bounds(numbers) local smallest, largest = numbers[1], numbers[1] for _, value in ipairs(numbers) do if value < smallest then smallest = value end if value > largest then largest = value end end return smallest, largest end local low, high = bounds({ 4, 1, 9 }) print(low, high)
var bounds := func(numbers: Array): return { "low": numbers.min(), "high": numbers.max() } var result = bounds.call([4, 1, 9]) print(result["low"], result["high"])
A dictionary is usually the better choice: the caller reads result["low"] rather than remembering which position came first. Godot's own APIs mostly return a single object with named properties for the same reason.
Real default arguments
Lua has no defaults, so the or fallback stands in — and, as the truthiness section showed, it quietly eats a legitimate false or, in GDScript, a legitimate zero.
local function greet(name, greeting) greeting = greeting or "Hello" -- the Lua idiom return greeting .. ", " .. name end print(greet("Ada")) print(greet("Ada", "Welcome"))
var greet := func(name: String, greeting: String = "Hello"): return greeting + ", " + name print(greet.call("Ada")) print(greet.call("Ada", "Welcome"))
A real default parameter applies only when the argument is genuinely absent, so it has no such hole. GDScript also checks the argument count, so calling with too few arguments is an error rather than silently passing nil.
Closures capture, in both
Lua closures capture the variable itself, so the counter increments across calls. GDScript lambdas capture by value at the moment they are created, which is a real semantic difference and not a syntax one.
local function make_counter() local count = 0 return function() count = count + 1 return count end end local next_value = make_counter() print(next_value(), next_value(), next_value())
var make_counter := func(): var count := [0] # boxed: lambdas capture by VALUE return func(): count[0] += 1 return count[0] var next_value = make_counter.call() print(next_value.call(), next_value.call(), next_value.call())
The workaround is to capture something mutable — an array or dictionary — and mutate through it, as above. Anyone porting Lua code that leans on upvalue mutation needs to check every closure; the code compiles and silently keeps returning 1.
Classes Instead of Metatables
A file is a class
Lua has no classes, so the constructor-plus-metatable pattern on the left is written by hand in every Lua codebase. In Godot a script file is a class, and class_name registers it globally.
local Enemy = {} Enemy.__index = Enemy function Enemy.new(health) return setmetatable({ health = health }, Enemy) end function Enemy:damage(amount) self.health = self.health - amount end local enemy = Enemy.new(100) enemy:damage(30) print(enemy.health)
# enemy.gd -- the file itself is the class. extends Node class_name Enemy var health: int = 100 func damage(amount: int) -> void: health -= amount # Elsewhere: var enemy := Enemy.new(); enemy.damage(30)
The correspondence is close: Enemy.__index = Enemy is what the class already is, setmetatable is what new() does, and the colon method is a func with an implicit self. Having it built in mostly means everyone writes it the same way.
self is implicit
Lua's colon is sugar that adds self as a first parameter, and you must remember which of : and . you meant. In GDScript the instance's members are simply in scope.
local counter = { count = 0 } function counter:increment() -- colon: self is an implicit parameter self.count = self.count + 1 end counter:increment() print(counter.count)
extends Node var count: int = 0 func increment() -> void: count += 1 # no self needed; 'self.count' also works
Writing self.count is legal and occasionally necessary — to disambiguate from a local of the same name, or to pass the instance itself. The everyday case needs no prefix at all, and there is no colon-versus-dot mistake to make.
Inheritance
Lua builds an inheritance chain out of two setmetatable calls and an __index on each level, and getting one wrong yields a silent nil rather than an error.
local Animal = {} Animal.__index = Animal function Animal.new(name) return setmetatable({ name = name }, Animal) end function Animal:speak() return self.name .. " makes a sound" end local Dog = setmetatable({}, { __index = Animal }) Dog.__index = Dog function Dog.new(name) return setmetatable(Animal.new(name), Dog) end function Dog:speak() return self.name .. " barks" end print(Dog.new("Rex"):speak())
# dog.gd extends Animal # the whole declaration class_name Dog func speak() -> String: return name + " barks" # super.speak() would call the parent's version
extends does the same wiring in one line, and super.speak() reaches the parent method — which in Lua means calling Animal.speak(self) directly. Godot supports only single inheritance; composing nodes in a scene is the intended alternative.
No metatables, no operator overloading
Metatables let a Lua table redefine arithmetic, comparison, indexing, calling and length. GDScript has no equivalent hook for user classes.
local Vector = {} Vector.__index = Vector Vector.__add = function(left, right) return setmetatable({ x = left.x + right.x }, Vector) end local sum = setmetatable({ x = 1 }, Vector) + setmetatable({ x = 2 }, Vector) print(sum.x)
# Godot's own math types already overload their operators: var sum := Vector2(1, 0) + Vector2(2, 0) print(sum.x) # But YOUR class cannot -- there is no __add hook to define.
Godot's built-in types — Vector2, Vector3, Transform2D, Color — do have overloaded operators because they are implemented in the engine. Anything of your own becomes a named method, and there is no __index fallback to give a class default values either.
__tostring becomes _to_string
This is the one metamethod that survives the move, under a different name. Godot calls the virtual method _to_string and consults it when converting an object to text.
local Point = {} Point.__index = Point Point.__tostring = function(self) return "Point(" .. self.x .. ", " .. self.y .. ")" end local point = setmetatable({ x = 3, y = 4 }, Point) print(tostring(point))
extends Node class_name Point var x: int var y: int func _to_string() -> String: return "Point(%d, %d)" % [x, y]
The underscore prefix marks a virtual method the engine calls, and there are many others — _ready, _process, _input. That naming convention is the closest thing GDScript has to Lua's metamethod table, but the set is fixed by the engine rather than open.
You Own the Loop vs. the Engine Owns It
🚨 love.update becomes _process on every node
This is the central shift, and it is architectural rather than syntactic. In LÖVE there is exactly one love.update(dt), you call into your own systems from it, and anything not reached simply does not run.
-- LÖVE: ONE update and ONE draw for the whole game. -- You own the loop, and you decide what gets stepped. local player = { x = 0, speed = 200 } local enemies = {} function love.update(dt) player.x = player.x + player.speed * dt for _, enemy in ipairs(enemies) do enemy.x = enemy.x + enemy.speed * dt end end
# Godot: EVERY node has its own _process, called by the engine. # There is no central update to add things to. extends CharacterBody2D var speed := 200.0 func _process(delta: float) -> void: position.x += speed * delta # Enemies each run their own _process; nothing here iterates them.
Godot inverts that: every node in the tree gets its own _process(delta), called by the engine, and a node you never mention still runs. Gaining a node means it starts updating; freeing it means it stops. The habit of maintaining your own list of things to step disappears.
A separate fixed-rate physics step
The accumulator loop on the left is the standard LÖVE answer to frame-rate-independent physics, and every Lua game engineer has written one.
-- LÖVE gives you one variable-rate update; a fixed timestep is -- something you implement yourself with an accumulator. local accumulator = 0 local FIXED_STEP = 1 / 60 function love.update(dt) accumulator = accumulator + dt while accumulator >= FIXED_STEP do -- step physics here accumulator = accumulator - FIXED_STEP end end
extends CharacterBody2D # Variable rate, once per rendered frame: func _process(delta: float) -> void: pass # Fixed rate, 60 Hz by default, independent of frame rate: func _physics_process(delta: float) -> void: velocity.y += 980.0 * delta move_and_slide()
Godot provides it: _physics_process runs at a fixed rate set in the project settings, while _process runs once per rendered frame. Putting movement in the wrong one is the common beginner mistake — anything touching the physics engine belongs in _physics_process.
Where state lives
In LÖVE the world is whatever tables you built, and a "spawn" is an insert into one of them. Nothing happens to an entity unless your update loop reaches it.
-- State is whatever tables you keep alive. You created them, -- you own them, and you decide when they die. local world = { entities = {} } local function spawn(x, y) world.entities[#world.entities + 1] = { x = x, y = y } end spawn(10, 20) print(#world.entities)
extends Node # State is the scene tree. Adding a child IS spawning; the node # then updates, draws and is freed as part of the tree. func spawn(enemy_scene: PackedScene, at: Vector2) -> void: var enemy := enemy_scene.instantiate() enemy.position = at add_child(enemy) # queue_free() removes it at the end of the frame.
In Godot the scene tree is the world. add_child is the spawn, and from that moment the node processes, renders and receives input on its own. queue_free is the removal, deferred to the end of the frame so it is safe to call mid-iteration — something the Lua version has to be careful about by hand.
Scenes are files, not constructors
A reusable object in LÖVE is a factory function returning a table, and everything about it — its sprite path, its hitbox, its children — is written in code.
-- A LÖVE "prefab" is a function that builds a table. local function make_enemy(x, y) return { x = x, y = y, health = 100, sprite = "enemy.png" } end local enemy = make_enemy(10, 20) print(enemy.health, enemy.sprite)
extends Node # A .tscn file is a saved subtree: nodes, properties, children, # and the scripts attached to them, all authored in the editor. @export var enemy_scene: PackedScene func _ready() -> void: var enemy := enemy_scene.instantiate() add_child(enemy)
Godot puts that in a .tscn file authored visually, and instantiate() stamps out a copy. The consequence worth internalizing is that much of what you would express in Lua as data in code becomes data in files, edited outside the script and referenced through @export.
delta is the same idea
A convergence in the middle of a section full of differences: both engines hand the frame's elapsed time to your update function, and both expect you to multiply by it.
-- Frame-rate independence works identically: multiply by dt. local speed = 200 local dt = 1 / 60 local distance = speed * dt print(string.format("%.2f", distance))
var speed := 200.0 var delta := 1.0 / 60.0 var distance := speed * delta print("%.2f" % distance)
The names differ only by convention — dt in LÖVE, delta in Godot — and both are seconds as a float. Anything that moves and is not multiplied by it will run at a different speed on a different monitor, in either engine.
Signals Instead of Polling
Signals replace checking every frame
The LÖVE pattern is to check a condition every frame and guard it with a flag so it only fires once. Godot has a first-class observer mechanism instead.
-- LÖVE: you poll. Every frame, you ask whether the thing happened. local player = { health = 100, dead = false } local function update() if player.health <= 0 and not player.dead then player.dead = true print("player died") end end player.health = 0 update()
extends Node signal died var health: int = 100 func take_damage(amount: int) -> void: health -= amount if health <= 0: died.emit() # announce it once, to whoever cares func _ready() -> void: died.connect(func(): print("player died"))
A signal is declared on the class, emitted when the event happens, and connected to by anything interested — including from the editor. Nothing polls, nothing needs a dead flag, and the emitter does not know who is listening.
Callbacks are the Lua equivalent
Lua has no event system, so every project grows one out of a table of functions — which is exactly what a signal is, with type declarations and editor support added.
-- The closest Lua analogue is a list of callback functions. local listeners = {} local function on(callback) listeners[#listeners + 1] = callback end local function emit(value) for _, callback in ipairs(listeners) do callback(value) end end on(function(value) print("got " .. value) end) emit("event")
# Signals are that pattern, built in and typed. # Declared: signal damaged(amount: int) # Connected: damaged.connect(func(amount): print("got ", amount)) # Emitted: damaged.emit(10) # A Callable is an ordinary value, so this part does run: var listeners: Array[Callable] = [] listeners.append(func(value): print("got ", value)) for callback in listeners: callback.call("event")
Because a Callable is an ordinary value, the hand-rolled version works in GDScript too, and the right column runs it. The advantage of real signals is that Godot manages the connections' lifetime: a freed node's connections go with it, where the Lua list would keep a dead closure alive.
await on a signal, where Lua yields
Lua suspends with coroutine.yield and something must call resume to continue. GDScript's await suspends until a signal fires, and the engine does the resuming.
-- Lua's answer is a coroutine you resume yourself. local routine = coroutine.create(function() coroutine.yield("waiting") print("resumed") end) print(select(2, coroutine.resume(routine))) coroutine.resume(routine)
extends Node func open_door() -> void: print("waiting") await get_tree().create_timer(1.0).timeout print("resumed after one second") # await also takes any signal: # await some_node.died
That is a real convenience for game code — waiting a second, or waiting for an animation to finish, becomes one line rather than a state machine. It is also less controllable: you cannot resume it early, and the function returns immediately to its caller while the rest runs later.
No pcall, and No Exceptions At All
🚨 There is no pcall and no try/catch
This is the largest missing feature for a Lua programmer. Lua's error unwinds to the nearest pcall, so a failure deep in a call chain can be handled above it. GDScript has no exceptions at all.
local ok, message = pcall(function() error("something broke", 0) end) print(ok, message) print("execution continues")
# GDScript has NO exceptions and no way to catch a runtime error. # You check first, or you return a status. var divisor := 0 if divisor == 0: push_error("something broke") # logs; does NOT unwind print(false, "something broke") print("execution continues")
push_error writes to the error log and to the debugger, and execution carries straight on. There is no construct that stops a runtime error from taking down the current call, so the discipline is to validate before acting and return a status or null on failure.
Engine calls return an error code
Both languages report failure by returning something rather than raising. Lua returns nil plus a message; Godot returns a value from its Error enum, where OK is zero.
-- Lua's convention is nil plus a message as a second return. local function parse(text) local number = tonumber(text) if number == nil then return nil, "not a number: " .. text end return number end local value, reason = parse("abc") print(value, reason)
# Godot's APIs return an Error enum you are expected to check. var text := "abc" if not text.is_valid_int(): print("not a number: ", text) else: print(text.to_int()) # Many engine calls look like: # var error := file.open(path, FileAccess.READ) # if error != OK: push_error("could not open")
Because OK is zero and zero is falsy, if error: reads as "if there was an error" and happens to be correct — the opposite of how the same shape behaves in Lua. Godot's string type also carries validators like is_valid_int, which Lua leaves to tonumber returning nil.
assert stops the game
Lua's assert raises a normal, catchable error and is always present, which makes it reasonable in shipping code.
local function withdraw(balance, amount) assert(amount > 0, "amount must be positive") return balance - amount end print(withdraw(100, 30)) print(pcall(withdraw, 100, -5))
var amount := 30 assert(amount > 0, "amount must be positive") print(100 - amount) # assert() is stripped from release builds entirely, # and a failure halts the game rather than raising something # a caller could catch -- there is nothing to catch it with.
Godot's stops execution in the debugger and is removed from release builds, so it is strictly a development check — never a validator for input that might legitimately be wrong. With no pcall to pair it with, the only safe use is for conditions you believe cannot happen.
Guarding against a missing node
Lua's and short-circuits into a safe chain, which is the usual way to read through a possibly-absent value. GDScript has no safe-navigation operator.
local target = nil -- Lua lets you chain safely with 'and'. local health = target and target.health print(health)
var target = null # No safe-navigation operator; test first. var health = target.health if target != null else null print(health) # is_instance_valid() additionally catches a FREED node, # which a plain null check would miss.
The extra hazard is Godot-specific: a node that has been freed is not null, and touching it is a crash. is_instance_valid(node) is the check that covers both cases, and it has no counterpart in Lua because a Lua table cannot be destroyed out from under a reference.
Gotchas for Lua Developers
A health check inverts
The truthiness rule in the shape that actually appears in game code, and the reason it is worth a second row: a zero health, a zero score, an empty inventory.
local health = 0 if health then print("this ALWAYS runs in Lua -- 0 is truthy") end
var health := 0 if health: print("never runs") else: print("0 is falsy, so this runs")
In Lua the guard is meaningless and always passes. In GDScript it correctly reports empty. Neither language warns, and the bug looks like a logic error somewhere else entirely.
Halving a dimension loses the fraction
The integer-division trap in the form it is met most: centering something by halving a width or a height.
local width = 1153 print(width / 2) -- 576.5
var width := 1153 print(width / 2) # 576 -- both operands are ints print(width / 2.0) # 576.5 print(width * 0.5) # 576.5, and the usual idiom
Multiplying by 0.5 is the common Godot idiom because it cannot be got wrong. Note the symptom is a one-pixel drift rather than an obvious failure, which is exactly why it survives review.
A lambda captured the value, not the variable
The closure difference from the Functions section, isolated because it is silent. Lua closures share the variable; GDScript lambdas capture its value when the lambda is created.
local total = 0 local add = function(amount) total = total + amount end add(5) add(5) print(total) -- 10: the upvalue is shared
var total := 0 var add := func(amount): total += amount # captures a COPY add.call(5) add.call(5) print(total) # 0 -- the outer variable never changed var boxed := [0] var add_boxed := func(amount): boxed[0] += amount add_boxed.call(5) print(boxed[0]) # 5
Nothing errors — the lambda runs, the arithmetic happens, and the outer variable is simply never touched. Capturing a mutable container and going through it is the workaround, and it is worth grepping every ported closure for this.
A missing dictionary key is an error
Reading an absent key is completely safe in Lua and returns nil, which is why Lua code reads optional configuration so casually. GDScript raises.
local settings = { width = 80 } print(settings.height) -- nil, and nothing goes wrong
var settings := { "width": 80 } # print(settings["height"]) # <- runtime error: invalid index print(settings.get("height")) # null print(settings.get("height", 24)) # 24, with a default print(settings.has("height")) # false
The three safe forms are get(key), get(key, default) and a has(key) test. This is the single most common crash when porting Lua code that treats missing data as ordinary.