False Friends — Same Shape, Different Answer
Indices start at 1
Start here, because every loop you have ever written has an off-by-one waiting in it. The two columns index the same table with the same numbers and get different elements.
var loot := ["sword", "shield", "potion"]
print(loot[0])
print(loot[1])
print(loot.size())local loot = {"sword", "shield", "potion"}
print(loot[0])
print(loot[1])
print(#loot)GDScript prints
sword then shield; Lua prints nil then sword, because loot[0] is simply a key nobody set. There is no error for reading a missing key — you get nil and find out later.Zero is true
Only
nil and false are falsy in Lua. Everything else is true, including 0, the empty string and an empty table.var health := 0
if health:
print("truthy")
else:
print("falsy")local health = 0
if health then
print("truthy")
else
print("falsy")
endSo the guard you write without thinking —
if health: meaning "not zero" — silently stops guarding. The Lua spelling has to be explicit: if health ~= 0 then. Note ~= rather than !=, which is its own small tax.Variables are global unless you say otherwise
This is the reverse of every scoping habit you have. A bare assignment inside a Lua function creates a global, visible everywhere and for the rest of the program.
func set_it() -> void:
var counter := 5
set_it()
print("counter is not visible out here")local function set_it()
counter = 5
end
set_it()
print(counter)The GDScript column cannot even express the bug — its
var is local and the name does not escape. In Lua the fix is one keyword, local, and forgetting it is the most common source of action-at-a-distance in Lua codebases.Hello World & Output
Hello, World
Byte-identical, and both columns run for real — the GDScript in an actual Godot engine, the Lua in an in-browser interpreter.
print("Hello, World!")print("Hello, World!")print is a global function in both languages, and both append a newline. This is most of what the two have in common at the surface.Putting a value in a message
GDScript's
% operator takes an array. Lua's string.format takes the values as ordinary arguments, with the same C-style placeholders you already know.var player_name := "Robi"
var score := 1200
print("%s scored %d" % [player_name, score])local player_name = "Robi"
local score = 1200
print(string.format("%s scored %d", player_name, score))
print(player_name .. " scored " .. score)Concatenation is
.., not + — and it converts numbers to strings on the way, which is why the second line works without a cast. Using + on a string in Lua tries arithmetic and fails.Variables & Scope
There are no type annotations at all
GDScript lets you annotate and then checks it at runtime. Lua has no annotation syntax whatsoever —
local is the only declaration, and a variable holds whatever it was last assigned.var speed: float = 200.0
var label := "boss"
print(speed)
print(label)local speed = 200.0
local label = "boss"
print(speed)
print(label)
print(type(speed), type(label))type() is how you ask at runtime, and it answers with a string. Luau, Roblox's dialect, adds gradual type annotations on top of exactly this — which is one reason a Godot developer might meet Luau rather than Lua.nil, and what it does to a table
Lua's
nil is GDScript's null with one extra job: assigning nil to a table key deletes it. There is no separate erase.var stats := {"strength": 12, "agility": 8}
stats.erase("agility")
print(stats.size())
print(stats.get("agility"))local stats = {strength = 12, agility = 8}
stats.agility = nil
local count = 0
for _ in pairs(stats) do count = count + 1 end
print(count)
print(stats.agility)That also means storing
nil is impossible — a key holding nil and a key that was never set are the same thing. Counting a table with non-integer keys needs a loop, because # only measures the array part.Strings
Length, case, and slicing
The
# operator gives length for strings as well as tables. The colon call, title:upper(), is Lua's method syntax — it passes the string as the first argument.var title := "Crystal Cavern"
print(title.length())
print(title.to_upper())
print(title.substr(0, 7))local title = "Crystal Cavern"
print(#title)
print(title:upper())
print(title:sub(1, 7))🚨
sub is 1-based and inclusive on both ends, so sub(1, 7) takes seven characters where substr(0, 7) starts at zero and takes seven. Both print Crystal here, and the arithmetic behind them is not the same.Splitting and joining
Lua has no split. The standard library is deliberately tiny, so splitting is a pattern match in a loop, and joining is
table.concat.var csv := "sword,shield,potion"
var items := csv.split(",")
print(items.size())
print(" + ".join(items))local csv = "sword,shield,potion"
local items = {}
for piece in csv:gmatch("[^,]+") do
items[#items + 1] = piece
end
print(#items)
print(table.concat(items, " + "))items[#items + 1] = piece is the idiomatic append — there is no push. Lua patterns are not regular expressions: [^,]+ looks familiar, but the syntax is its own smaller language with % as the escape rather than \\.Numbers & Math
The math you use every frame
GDScript puts these in the global scope; Lua groups them on the
math table. Otherwise the names line up almost exactly.print(abs(-7))
print(min(3, 9))
print(sqrt(16.0))
print(floor(2.6))print(math.abs(-7))
print(math.min(3, 9))
print(math.sqrt(16.0))
print(math.floor(2.6))There is no
clamp in the Lua standard library — you write math.max(low, math.min(high, value)). That absence is representative: Lua ships a small core and expects the host to supply the rest, which is exactly what an engine does.Division and integer division
GDScript divides two integers as integers. Lua 5.3's
/ always produces a float, and // is the separate floor-division operator.print(7 / 2)
print(7 % 2)
print(int(7 / 2))print(7 / 2)
print(7 % 2)
print(7 // 2)So the same expression gives
3 on the left and 3.5 on the right — the same trap Python sets, arriving from a different direction. Before 5.3 Lua had one number type and no // at all, which is why older Lua code is full of math.floor.Tables — The Only Structure
Array and Dictionary are the same thing
Lua has exactly one data structure. A table holds numeric keys and string keys at the same time, so the array and the dictionary in the left column are one value in the right.
var loot := ["sword", "shield"]
var stats := {"strength": 12}
print(loot.size())
print(stats["strength"])local both = {"sword", "shield", strength = 12}
print(#both)
print(both[1])
print(both.strength)
print(both["strength"])both.strength and both["strength"] are the same access written two ways. #both counts only the contiguous numeric part, so it reports 2 and ignores the named key entirely — which is why counting a mixed table needs pairs.Two ways to walk a table
ipairs walks the numeric part in order and stops at the first gap. pairs walks every key in an unspecified order. Picking the wrong one is a real bug rather than a style choice.var loot := ["sword", "shield"]
for item in loot:
print(item)
var stats := {"strength": 12, "agility": 8}
var keys := stats.keys()
keys.sort()
for key in keys:
print("%s=%d" % [key, stats[key]])local loot = {"sword", "shield"}
for index, item in ipairs(loot) do
print(index, item)
end
local stats = {strength = 12, agility = 8}
local keys = {}
for key in pairs(stats) do keys[#keys + 1] = key end
table.sort(keys)
for _, key in ipairs(keys) do
print(key .. "=" .. stats[key])
endBoth give you the key as well as the value, which is why the GDScript habit of looping over indices has no counterpart.
_ is the conventional name for a binding you are ignoring — it is an ordinary variable, not syntax.Sorting by something
The comparator means the same thing in both — true when the first argument sorts first.
table.sort is a free function taking the table, rather than a method on it.var enemies := [
{"name": "bat", "hp": 12},
{"name": "golem", "hp": 90},
{"name": "slime", "hp": 30},
]
enemies.sort_custom(func(a, b): return a["hp"] < b["hp"])
for enemy in enemies:
print("%s %d" % [enemy["name"], enemy["hp"]])local enemies = {
{name = "bat", hp = 12},
{name = "golem", hp = 90},
{name = "slime", hp = 30},
}
table.sort(enemies, function(a, b) return a.hp < b.hp end)
for _, enemy in ipairs(enemies) do
print(enemy.name .. " " .. enemy.hp)
endNote that the data shape barely changed: a table of tables with named keys is as natural in Lua as a dictionary is in GDScript, so unlike the C#, C++ and Rust pages there is no pressure to introduce a typed record.
Control Flow
if / elseif / else / end
Blocks are delimited by keywords rather than indentation:
then opens and end closes. It is elseif, one word, which is neither GDScript's elif nor C's two words.var health := 45
if health > 70:
print("healthy")
elif health > 25:
print("hurt")
else:
print("critical")local health = 45
if health > 70 then
print("healthy")
elseif health > 25 then
print("hurt")
else
print("critical")
endBecause indentation carries no meaning, a missing
end is reported at the end of the file rather than where you left it out — the most common way a Lua parse error points somewhere unhelpful.There is no match statement
Lua has no
match and no switch. The idiom is a table of functions keyed by the value, which is dispatch by lookup rather than by comparison.var state := "walking"
match state:
"idle":
print("standing still")
"walking", "running":
print("moving")
_:
print("unknown")local state = "walking"
local actions = {
idle = function() print("standing still") end,
walking = function() print("moving") end,
running = function() print("moving") end,
}
local action = actions[state] or function() print("unknown") end
action()This is Lua's answer to a lot of things: when the language lacks a construct, you build it out of tables and functions. The
or supplies the default, because or returns its second operand when the first is nil — an idiom you will see constantly.and and or return values, not booleans
In Lua
or returns the first operand that is not nil or false, and and returns the second when the first is truthy. They yield values, not booleans.var supplied = null
var name = supplied if supplied != null else "anonymous"
print(name)
var ready := true
print(ready and "go" or "wait")local supplied = nil
local name = supplied or "anonymous"
print(name)
local ready = true
print(ready and "go" or "wait")That makes
x or default the standard way to supply a fallback, and cond and a or b a working ternary — with one trap: it breaks when a is itself false or nil, because then the and falls through to b.Loops
Counting
🚨 Lua's numeric
for is inclusive of its end value, where range() excludes it. The same numbers therefore mean different loops.for i in range(3):
print(i)
for i in range(2, 8, 2):
print(i)for i = 0, 2 do
print(i)
end
for i = 2, 6, 2 do
print(i)
endTo match
range(3) you write for i = 0, 2, and to match range(2, 8, 2) you write 2, 6, 2. Combined with 1-based indexing, this is where most porting mistakes live.while, and a loop GDScript does not have
while is the same. repeat … until is Lua's bottom-tested loop: the body always runs at least once, and the condition says when to stop rather than when to continue.var charges := 3
while charges > 0:
print(charges)
charges -= 1
var attempts := 0
while true:
attempts += 1
if attempts >= 2:
break
print(attempts)local charges = 3
while charges > 0 do
print(charges)
charges = charges - 1
end
local attempts = 0
repeat
attempts = attempts + 1
until attempts >= 2
print(attempts)Note there is no
+= in Lua — you write the assignment out. There is also no continue; the usual workaround is a goto to a label at the end of the body, which is one of the few places Lua feels older than it is.Functions & Multiple Returns
Declaring a function
No parameter types, no return type, and
local in front — without it the function becomes a global, exactly as with any other assignment.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))local function damage_after_armor(damage, armor)
return math.max(damage - armor, 0)
end
print(damage_after_armor(30, 12))
print(damage_after_armor(5, 12))Calling with too few arguments is not an error: the missing ones are
nil, and you find out when the arithmetic fails. Extra arguments are silently discarded. That is the cost of the flexibility the rest of this page keeps showing.Returning more than one value
Lua functions genuinely return several values, not a container holding them. The caller lists as many names as it wants and the rest are dropped.
func split_damage(total: int) -> Dictionary:
return {"physical": total / 2, "fire": total - total / 2}
var parts := split_damage(9)
print(parts["physical"])
print(parts["fire"])local function split_damage(total)
local physical = total // 2
return physical, total - physical
end
local physical, fire = split_damage(9)
print(physical)
print(fire)This is why
pcall can return "did it work" and "what came back" together, and why string.gsub hands you the result and a count. GDScript has to package such things in a dictionary or array; Lua does not.Taking any number of arguments
The
... parameter collects however many arguments were passed, and {...} packs them into a table. GDScript has no variadic form, so the caller builds the array instead.func total(numbers: Array) -> int:
var sum := 0
for value in numbers:
sum += value
return sum
print(total([1, 2, 3]))local function total(...)
local sum = 0
for _, value in ipairs({...}) do
sum = sum + value
end
return sum
end
print(total(1, 2, 3))select("#", ...) counts them, which matters because a nil in the middle makes {...} shorter than the argument list. That gap between "arguments passed" and "table length" is a genuine Lua sharp edge.Metatables Instead of Classes
A class, assembled from a table
Lua has no classes. A "class" is a table you set up yourself:
__index tells Lua where to look when a key is missing, and setmetatable attaches that lookup to each new instance.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())local Potion = {}
Potion.__index = Potion
function Potion.new(strength)
return setmetatable({strength = strength}, Potion)
end
function Potion:describe()
return "potion of " .. self.strength
end
local small = Potion.new(5)
print(small:describe())The colon does two different jobs:
function Potion:describe() declares an implicit self parameter, and small:describe() passes the receiver as it. Writing a dot in either place is the single most common Lua mistake, and it fails with self being nil.Inheritance, also by hand
Inheritance is the same mechanism applied twice: give the child table a metatable whose
__index is the parent, so a missing key walks up the chain.class Enemy:
func speak() -> String:
return "..."
class Bat extends Enemy:
func speak() -> String:
return "screech"
var creature: Enemy = Bat.new()
print(creature.speak())local Enemy = {}
Enemy.__index = Enemy
function Enemy.new() return setmetatable({}, Enemy) end
function Enemy:speak() return "..." end
local Bat = setmetatable({}, {__index = Enemy})
Bat.__index = Bat
function Bat.new() return setmetatable({}, Bat) end
function Bat:speak() return "screech" end
local creature = Bat.new()
print(creature:speak())Nine lines replace one
extends, and every Lua codebase writes them slightly differently — which is why nearly every Lua project ships its own tiny class library. That variety is what a Godot developer notices first when reading Lua game code.Operators are just more metatable keys
Godot gives you
Vector2 with arithmetic already defined. In Lua the same capability is a metatable key: __add for +, __tostring for printing, __eq for equality.var first := Vector2(1, 2)
var second := Vector2(3, 4)
print(first + second)local Point = {}
Point.__index = Point
function Point.new(x, y)
return setmetatable({x = x, y = y}, Point)
end
Point.__add = function(a, b) return Point.new(a.x + b.x, a.y + b.y) end
Point.__tostring = function(p) return "(" .. p.x .. ", " .. p.y .. ")" end
print(tostring(Point.new(1, 2) + Point.new(3, 4)))This is the mechanism GDScript reserves for built-in types, handed to you for your own. It is also why a Lua game engine can make vectors feel native without any language support — the engine just supplies the metatable.
Coroutines
Coroutines pass values both ways
This is where Lua is genuinely more capable than GDScript.
coroutine.yield both hands a value out and receives the value passed to the next resume, so a coroutine is a two-way conversation rather than a pause.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")local function counter()
local total = 0
while true do
local added = coroutine.yield(total)
total = total + added
end
end
local running = coroutine.wrap(counter)
running()
print(running(5))
print(running(3))GDScript's
await only resumes — nothing can be sent back in. That is why Lua game code uses coroutines for behavior scripts and dialogue trees, where each resume feeds the next decision in. coroutine.wrap turns one into a plain callable; coroutine.create plus resume is the form that reports errors instead of raising.Errors
GDScript cannot raise; Lua can, and pcall catches
GDScript has no exceptions —
push_error writes to the debugger and execution continues. Lua has error to raise and pcall to call something in protected mode.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))local function withdraw(balance, amount)
if amount > balance then
error("insufficient funds")
end
return balance - amount
end
print(withdraw(100, 30))
local ok, result = pcall(withdraw, 100, 500)
print(ok, result)Look at what the GDScript column is forced to do: return the unchanged balance, which cannot be told apart from a withdrawal of zero.
pcall returns the two values this page keeps meeting — did it work, and what came back — so the failure is handled without any try block.Scripting Godot in Lua
A Godot node scripted in Lua
lua-gdextension registers Lua as a scripting language alongside GDScript and C#, so the same lifecycle callbacks arrive with the same names. Verified 2026-09-01: version 0.8.2, supporting Godot 4.5.1 through 4.8, and marked unstable.
extends Node2D
var speed := 200.0
func _ready() -> void:
print("ready")
func _process(delta: float) -> void:
position.x += speed * delta-- With gilzoide/lua-gdextension installed, a .lua file is a script you can
-- attach to a node, the same way a .gd file is.
local Mover = {
extends = "Node2D",
}
function Mover:_ready()
print("ready")
end
function Mover:_process(delta)
self.position = self.position + Vector2(self.speed * delta, 0)
end
Mover.speed = 200.0
return MoverThe shape is the shape this whole section has been building toward — a table with functions in it, returned from the file. What it buys over GDScript is not speed but familiarity for people arriving from Love2D, Defold or Roblox, and the option of one language across several engines.
A sandboxed Lua state, for mods
This is the strongest practical argument for Lua in a Godot project, and it is not about the language at all.
load takes an environment table, so a script sees exactly the globals you put in it and nothing else.# Godot has no sandboxed script host of its own. Running untrusted
# GDScript means loading it into the same VM your game runs in.
var untrusted := "print('a mod ran')"
print("GDScript has no way to run that safely")-- lua-gdextension exposes LuaState objects from GDScript, each with its own
-- globals, so a mod cannot reach your game's internals unless you hand them over.
local sandbox = {}
sandbox.print = print
sandbox.allowed_score = 10
local mod_source = "print('a mod ran with ' .. allowed_score)"
local mod = load(mod_source, "mod", "t", sandbox)
mod()Godot has no equivalent — running untrusted GDScript means loading it into your own VM. lua-gdextension exposes
LuaState objects to GDScript for precisely this, which is why a game wanting user mods reaches for Lua whatever it is otherwise written in.Drawing It, Side by Side
A starfield, drawn and printed
The GDScript column below runs in a real Godot engine and draws a starfield into the canvas. The Lua column runs too, and prints one — because a bare Lua interpreter has no canvas, only a host that might give it one.
extends Control
var stars: PackedVector2Array = []
var sizes: PackedFloat32Array = []
func _ready() -> void:
randomize()
var pane := get_viewport_rect().size
for i in 90:
stars.append(Vector2(randf() * pane.x, randf() * pane.y))
sizes.append(randf_range(0.7, 2.6))
queue_redraw()
func _draw() -> void:
var pane := get_viewport_rect().size
draw_rect(Rect2(Vector2.ZERO, pane), Color(0.04, 0.05, 0.12), true)
for i in stars.size():
var glow := sizes[i] / 2.6
draw_circle(stars[i], sizes[i], Color(1.0, 0.98, 0.9, 0.25 + glow * 0.75))math.randomseed(7)
local width, height = 46, 9
local rows = {}
for y = 1, height do
rows[y] = {}
for x = 1, width do rows[y][x] = " " end
end
for _ = 1, 40 do
local x = math.random(1, width)
local y = math.random(1, height)
rows[y][x] = math.random() > 0.6 and "*" or "."
end
for y = 1, height do
print(table.concat(rows[y]))
endThat is the honest picture of Lua's place: the language brings tables, closures and coroutines, and the host brings drawing. Love2D gives it
love.graphics, Defold gives it its own, Godot gives it the same draw_circle the left column calls — and none of that is Lua, which is exactly why Lua turns up in all of them.