VXLua Scripting
VXLua is a Lua-inspired scripting language built into Voxelgia Builder. Attach scripts to any part in your map to create interactive gameplay — moving platforms, triggered sounds, dynamic lighting, and more.
What is VXLua?
VXLua is a Lua-subset interpreter implemented as a Godot AutoLoad singleton (VXLuaRuntime). It runs map scripts as cooperative coroutines — each script can pause via wait() and resume on the next frame tick without blocking the rest of the game.
Scripts are attached to Script objects inside the Builder. Each script has access to its parent part hierarchy through the built-in script object, and can reach any named part in the world via part("Name").
Supported Language Features
| Feature | Notes |
|---|---|
| local variables | Lexically scoped, per-block |
| if / elseif / else | Full branching |
| while / for | Numeric for only — for i = 1, 10 do |
| loop { } | Infinite loop sugar — must call wait() |
| functions | First-class, closures, recursion |
| tables | Dict + array hybrid, 1-indexed arrays |
| break / return | Full control flow |
| -- comments | Line comments and --[[ block ]] |
| .. concatenation | Strings and numbers |
| Vector3 / Color | First-class value types with arithmetic |
for k,v in pairs(), no coroutine library, no metatables, and no require().Quick Start
From zero to a scripted spinning platform in under two minutes.
1 · Add a Script Object
In Voxelgia Builder, select a part in the Explorer, open the Insert menu, and choose Script. A Script object is inserted as a child of the selected part. To edit it, select the Script in the Explorer and press Edit Script (or use the Edit Script button in the toolbar). This opens the VXLua IDE.
2 · Write Your Script
-- Get a reference to the parent part local platform = script.parent -- Spin forever, 45 degrees per tick loop { platform.spin("y", 45 * 0.05) wait(0.05) }
3 · Playtest
Hit the Play button in Builder. Scripts start automatically when the playtest begins. Check the Output panel for any errors.
loop { } blocks must call wait() inside the body. A loop without wait() will hit the 100,000-op instruction limit and be force-stopped.More Examples
Color pulse
local p = script.parent local t = 0 loop { t = t + 0.05 local r = math.sin(t) * 0.5 + 0.5 local g = math.cos(t * 1.3) * 0.5 + 0.5 p.set_color(Color(r, g, 0.8, 1)) wait(0.05) }
Move between two points
local p = script.parent local a = Vector3(0, 1, 0) local b = Vector3(0, 8, 0) loop { p.move_to(a) wait(2) p.move_to(b) wait(2) }
Script Lifecycle
How VXLua scripts are parsed, started, and resumed during playtest.
Execution Model
When the Builder enters playtest, VXLuaRuntime.run_script() is called for each Script object in the world. The source is tokenized and parsed into an AST, then wrapped in a VXCoroutine.
Each frame, the playtest runner calls coro.step() on every running coroutine. step() executes statements until it hits a wait() call, at which point it returns the requested delay in seconds and suspends. The coroutine resumes automatically after the delay expires.
step() call can execute up to 100,000 instructions before being force-stopped. If you see "Script exceeded max instruction limit" in Output, add a wait() inside your loop.Coroutine Frame Stack
The VXCoroutine maintains an explicit frame stack. Each loop or block pushes a frame; wait() returns a signal that unwinds execution and preserves the entire stack for the next resume. This means statements after a wait() run exactly once from where they left off — the script does not restart from the top.
Script Context Variables
| Variable | Type | Description |
|---|---|---|
| script | PartProxy | The Script node itself as a PartProxy. Use script.parent to reach the attached part. |
| script_node | Node3D (raw) | Raw Godot node — for advanced use only; prefer the proxy. |
| world | Node3D (raw) | The world root node. Passed to part() internally. |
Playtest vs. Editor
When you call part("MyBlock") during playtest, VXLua finds the live physics copy of that part (nodes with the playtest_runtime meta) rather than the static editor node. This means changes are visible immediately during play.
Syntax & Types
VXLua syntax is a strict subset of Lua 5.x. If you know Lua, you already know VXLua — just mind the missing features.
Variables
All variables are declared with local. There are no implicit globals — everything lives in the script's environment.
local x = 10 local name = "platform" local flag = true local pos = Vector3(0, 5, 0)
Types
| Type | Example | Notes |
|---|---|---|
| nil | nil | Unset / absent value. Falsy. |
| boolean | true, false | Only false and nil are falsy. 0 is truthy. |
| number | 3.14, 1e3 | Always float internally. |
| string | "hello", 'world' | Escapes: \n \t \\ \" \' |
| table | {1, 2, 3} | Ordered dict. Array indices are 1-based. |
| function | function(x) ... end | First-class, closures supported. |
| Vector3 | Vector3(x,y,z) | Fields: .x .y .z. Arithmetic works. |
| Color | Color(r,g,b,a) | Fields: .r .g .b .a .h .s .v |
Tables
-- Array-style (1-indexed!) local colors = { Color(1,0,0,1), Color(0,1,0,1) } local first = colors[1] -- red -- Dict-style local cfg = { speed = 3.0, height = 5 } print(cfg.speed) -- 3.0
t[1] is the first element. t[0] returns nil.Comments
-- single-line comment
--[[
multi-line block comment
]]Control Flow
if / elseif / else
if x > 10 then print("big") elseif x > 5 then print("medium") else print("small") end
while
local i = 0 while i < 5 do print(i) i = i + 1 end
loop { } with wait().for (numeric)
-- for i = start, stop [, step] for i = 1, 5 do print(i) -- 1 2 3 4 5 end for i = 10, 1, -2 do print(i) -- 10 8 6 4 2 end
loop { } — Infinite Loop
loop { ... } is VXLua's syntax sugar for an infinite loop. It is the recommended pattern for gameplay scripts that run forever.
loop { -- runs every 0.1 seconds forever wait(0.1) }
wait() inside loop { }. A loop without a wait consumes all 100,000 ops in one frame and is killed by the runtime.break
for i = 1, 100 do if i == 5 then break end print(i) end
Functions
Declaration
-- Named function function greet(name) print("Hello, " .. name) end -- Local named function local function square(n) return n * n end -- First-class / anonymous local add = function(a, b) return a + b end
Closures
Functions capture their enclosing environment at the time of definition.
local count = 0 local function increment() count = count + 1 return count end print(increment()) -- 1 print(increment()) -- 2
wait() inside user-defined functions called from within a loop. The coroutine stack only tracks top-level control flow frames. Put wait() at the loop body level instead.Operators
Arithmetic
| Op | Description | Example |
|---|---|---|
| + | Add (numbers or Vector3+Vector3) | 3 + 4 → 7 |
| - | Subtract / unary negate | 10 - 3 → 7 |
| * | Multiply (number×number or Vector3×number) | v * 2 |
| / | Divide (float) | 7 / 2 → 3.5 |
| // | Integer (floor) divide | 7 // 2 → 3 |
| % | Modulo | 7 % 3 → 1 |
| ^ | Power | 2 ^ 8 → 256 |
Comparison & Logic
| Op | Description |
|---|---|
| == | Equal |
| ~= | Not equal (Lua style — not !=) |
| < <= > >= | Numeric comparison |
| and | Logical and (returns value, not bool) |
| or | Logical or (returns value, not bool) |
| not | Logical not |
String
| Op | Description | Example |
|---|---|---|
| .. | Concatenation | "hi" .. " there" → "hi there" |
| # | Length (string or table) | #"hello" → 5 |
Precedence (highest → lowest)
^ * / // % + - .. < <= > >= == ~= and or
Global Functions
Available in every VXLua script without any prefix.
Suspends the script for seconds seconds, then resumes automatically. Mandatory inside any loop { }.
wait(1) -- pause 1 second wait(0.016) -- ~60fps tick
Finds a part in the world by name. Returns nil and logs an error if not found. During playtest, prefers the live physics copy over the editor node.
local door = part("Door") door.set_visible(false)
Finds a Sound object by name. Returns nil if not found or if the node isn't a sound asset.
Logs a message to the Builder Output panel. Multiple arguments are joined with spaces.
Returns true if a part with the given name exists in the world.
Returns a 1-indexed table of all selectable parts whose names start with prefix.
local pillars = find_parts_with_prefix("Pillar_") for i = 1, table.size(pillars) do pillars[i].set_color(Color(1,0,0,1)) end
Converts any value to its string representation.
Parses a string or number to a float. Returns 0.0 on failure.
Returns the type name: "nil", "boolean", "number", "string", "table", "function", "Vector3", "Color", or "userdata".
Returns Unix timestamp in seconds. Useful for computing real elapsed time.
Returns engine ticks in milliseconds since launch. Higher precision than time().
Linearly interpolates between two colors. t is clamped to [0, 1].
Creates a Color from hue (0–1), saturation, value, and optional alpha. Great for rainbow effects.
local hue = 0 loop { hue = (hue + 0.01) % 1 script.parent.set_color(color_from_hsv(hue, 1, 1)) wait(0.05) }
Part API
A PartProxy is the primary object for interacting with parts. Obtain one via part("Name") or script.parent.
Properties
nil if none.p.position = Vector3(0,5,0)) automatically pushes the change to the Godot node.Movement
Teleports the part to pos in world space. Same as assigning p.position.
Alias for move_to.
Returns the live global position directly from the Godot node.
Rotates the part to face target. Handles degenerate cases (target directly above/below) safely.
Returns the distance from this part to a world-space position or to another PartProxy.
Rotation
Rotates the part by degrees around axis. Axis can be "x", "y", "z" or a Vector3.
p.spin("y", 90) -- rotate 90° around Y p.spin(Vector3(1,1,0), 45) -- custom axis
Sets absolute rotation in degrees.
Returns current rotation in degrees (live from node).
Color
Sets the mesh material color.
Returns the current color. Returns white if no material is found.
Visibility
Show or hide the part instantly.
Set visibility programmatically.
Returns current visibility state.
Size
Sets the mesh size. Works on Box, Sphere, and Cylinder meshes.
Returns the current mesh size.
Hierarchy
Same as reading p.parent.
Returns a 1-indexed table of all selectable child parts.
Returns the number of selectable children.
Sound API
Control sound objects placed in your map via sound("Name").
true to play, false to stop.0.0. Negative = quieter.20.0.local sfx = sound("AmbientHum") sfx.enabled = true sfx.volume = -6 -- 6dB quieter sfx.distance = 40 -- audible from further away
math.*
Standard math library — call as math.functionName().
| Function | Signature | Description |
|---|---|---|
| math.sin | (x) | Sine (radians) |
| math.cos | (x) | Cosine |
| math.tan | (x) | Tangent |
| math.asin | (x) | Arcsine |
| math.acos | (x) | Arccosine |
| math.atan | (x) | Arctangent |
| math.atan2 | (y, x) | 2-argument arctangent |
| math.sqrt | (x) | Square root |
| math.abs | (x) | Absolute value |
| math.floor | (x) | Round down |
| math.ceil | (x) | Round up |
| math.round | (x) | Round to nearest integer |
| math.max | (a, b) | Larger of a, b |
| math.min | (a, b) | Smaller of a, b |
| math.clamp | (x, min, max) | Clamp x to [min, max] |
| math.lerp | (a, b, t) | Linear interpolate |
| math.pow | (x, y) | x raised to y |
| math.log | (x) | Natural logarithm |
| math.exp | (x) | e^x |
| math.sign | (x) | -1, 0, or 1 |
| math.fmod | (x, y) | Float remainder |
| math.random | (min?, max?) | Random float in [min, max] (default 0–1) |
| math.random_int | (min?, max?) | Random integer |
| math.distance | (a: Vector3, b: Vector3) | Distance between two points |
| math.deg2rad | (deg) | Degrees to radians |
| math.rad2deg | (rad) | Radians to degrees |
| math.pi | constant | 3.14159… |
| math.tau | constant | 6.28318… (2π) |
| math.huge | constant | Infinity |
string.*
| Function | Signature | Description |
|---|---|---|
| string.format | (fmt, ...) | Printf-style formatting (GDScript % operator) |
| string.upper | (s) | Uppercase |
| string.lower | (s) | Lowercase |
| string.len | (s) | Character count |
| string.sub | (s, i, j?) | Substring (1-based, inclusive) |
| string.rep | (s, n) | Repeat string n times |
| string.reverse | (s) | Reverse string |
| string.find | (s, pattern) | 1-based index of first match, or nil |
| string.contains | (s, sub) | True if s contains sub |
| string.starts_with | (s, prefix) | True if s begins with prefix |
| string.ends_with | (s, suffix) | True if s ends with suffix |
| string.trim | (s) | Strip leading/trailing whitespace |
| string.split | (s, sep?) | Split on separator → 1-indexed table |
| string.replace | (s, old, new) | Replace all occurrences |
table.*
table.* functions handle index management for you.Appends value to the end of table t.
Removes and returns the element at index i (default: last).
Returns the number of entries. Works on both array and dict tables.
Joins all integer-keyed values with sep (default: empty string), in order from 1.
Vector3
3D vector type for positions, rotations, and sizes.
Constructor
Vector3(x, y, z) -- all args default to 0 Vector3() -- (0, 0, 0) Vector3(1, 2, 3) -- (1, 2, 3)
Field Access & Arithmetic
local v = Vector3(1, 2, 3) print(v.x, v.y, v.z) -- 1 2 3 local d = v * 3 -- Vector3(3,6,9) local s = v + Vector3(1,0,0) -- Vector3(2,2,3)
Helper Functions
| Function | Signature | Description |
|---|---|---|
| vec3_dot | (a, b) | Dot product |
| vec3_cross | (a, b) | Cross product |
| vec3_length | (v) | Magnitude |
| vec3_normalize | (v) | Unit vector |
| vec3_lerp | (a, b, t) | Interpolate, t clamped [0,1] |
| vec3_distance | (a, b) | Distance between two points |
Constants
| Name | Value |
|---|---|
| Vector3_ZERO | (0, 0, 0) |
| Vector3_ONE | (1, 1, 1) |
| Vector3_UP | (0, 1, 0) |
| Vector3_DOWN | (0, -1, 0) |
| Vector3_LEFT | (-1, 0, 0) |
| Vector3_RIGHT | (1, 0, 0) |
| Vector3_FORWARD | (0, 0, -1) |
| Vector3_BACK | (0, 0, 1) |
Color
RGBA color type. All channels are floats from 0.0 to 1.0.
Constructor
Color(r, g, b, a) -- a defaults to 1.0 Color(1, 0, 0) -- solid red Color(0, 1, 0, 0.5) -- semi-transparent green
Fields
| Field | Description |
|---|---|
| .r | Red (0–1) |
| .g | Green (0–1) |
| .b | Blue (0–1) |
| .a | Alpha (0–1) |
| .h | Hue |
| .s | Saturation |
| .v | Value (brightness) |
Global Color Functions
| Function | Description |
|---|---|
| color_lerp(a, b, t) | Interpolate between two colors |
| color_from_hsv(h, s, v, a?) | Create Color from HSV (all 0–1) |
scene.*
Read-only information about the current session.
| Member | Type | Description |
|---|---|---|
| scene.player_count() | number | Number of players in session. Returns 1 in editor playtest. |
| scene.gravity | number | World gravity constant (9.8). Read as a value: local g = scene.gravity |
| scene.world_name() | string | Current map name. Returns "singleplayer" in editor playtest. |
local n = scene.player_count() print("Players: " .. tostring(n))
Common Patterns
Practical recipes for the most common scripting tasks.
Smooth lerp movement
local p = script.parent local start = Vector3(0, 1, 0) local goal = Vector3(0, 8, 0) local t = 0 loop { t = (t + 0.01) % 1 local pos = vec3_lerp(start, goal, math.sin(t * math.pi) * 0.5 + 0.5) p.move_to(pos) wait(0.016) }
Proximity trigger
local gate = script.parent local trigger = part("TriggerZone") loop { if gate.distance_to(trigger) < 3 then gate.hide() else gate.show() end wait(0.1) }
Timed sequence (runs once)
local p = script.parent p.set_color(Color(1,0,0)) -- red wait(2) p.set_color(Color(1,1,0)) -- yellow wait(1) p.set_color(Color(0,1,0)) -- green
Group operation
local lights = find_parts_with_prefix("Light_") local n = table.size(lights) local on = true loop { for i = 1, n do lights[i].set_visible(on) end on = not on wait(0.5) }
Rainbow color cycle
local p = script.parent local hue = 0 loop { hue = (hue + 0.005) % 1 p.set_color(color_from_hsv(hue, 1, 1)) wait(0.016) }
Oscillating size
local p = script.parent local t = 0 loop { t = t + 0.05 local s = 1 + math.sin(t) * 0.3 p.set_size(Vector3(s, s, s)) wait(0.05) }
Limits & Gotchas
Things to know before you spend 20 minutes debugging.
Instruction Limit
loop { } without wait() exhausts this instantly. Always place at least one wait() inside infinite loops.While Loop Cap
while loops. Beyond this the loop is silently abandoned. Use loop { wait() } for long-running sequences.Arrays Are 1-Indexed
local t = { "a", "b", "c" } print(t[1]) -- "a" ✓ print(t[0]) -- nil ✗ common mistake
No Generic for / pairs
-- ✓ Correct for i = 1, table.size(t) do print(t[i]) end -- ✗ NOT supported in VXLua for k, v in pairs(t) do end
wait() Inside User Functions
wait() inside a user-defined function called from a loop may not resume correctly. Keep wait() at the top level of your loop { } body.Not-Equal Is ~=, Not !=
if x ~= 0 then -- ✓ correct if x != 0 then -- ✗ parse error
part() Returns nil If Not Found
Calling methods on nil silently does nothing. Use part_exists() to check first if you're unsure.
Proxy Properties vs Live Values
Reading p.position returns the cached value from when the proxy was created or last written. For the absolute latest live value, call p.get_position() instead.
Opening the Script Editor
Scripts do not open by double-clicking. Select the Script object in the Explorer panel, then use the Edit Script button to open the VXLua IDE.