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")
endvar 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 += dxThat 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 compiletypeof 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 compileA
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 finevar 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 lookupIndexing 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 fallbackvar 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 operatorprint(7 / 2) # 3 -- INTEGER division, because both are ints
print(7.0 / 2) # 3.5
print(float(7) / 2) # 3.5, the usual fixThis 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) -- trueprint(typeof(7) == TYPE_INT)
print(typeof(7.0) == TYPE_FLOAT)
print(7 == 7.0) # true: compared numericallyA
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 COUNTSo 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)
endvar 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 typeA 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")
endvar 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")
endvar 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 worksWriting
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 versionextends 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
endextends 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.diedThat 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")
endvar 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.5var 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 idiomMultiplying 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 sharedvar 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]) # 5Nothing 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 wrongvar 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")) # falseThe 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.