Voxelgia Builder · Scripting Reference

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.

Quick Start
Write your first script
📦
Part API
Move, color, resize parts
🔁
Patterns
Loops, timers, triggers
⚠️
Gotchas
Common mistakes to avoid

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

FeatureNotes
local variablesLexically scoped, per-block
if / elseif / elseFull branching
while / forNumeric for only — for i = 1, 10 do
loop { }Infinite loop sugar — must call wait()
functionsFirst-class, closures, recursion
tablesDict + array hybrid, 1-indexed arrays
break / returnFull control flow
-- commentsLine comments and --[[ block ]]
.. concatenationStrings and numbers
Vector3 / ColorFirst-class value types with arithmetic
Note: VXLua is a subset of Lua — not all Lua features are present. There is no generic for k,v in pairs(), no coroutine library, no metatables, and no require().
Getting Started

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.

Warning: 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)
}
Getting Started

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.

Note: A single 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

VariableTypeDescription
scriptPartProxyThe Script node itself as a PartProxy. Use script.parent to reach the attached part.
script_nodeNode3D (raw)Raw Godot node — for advanced use only; prefer the proxy.
worldNode3D (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.

Language

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

TypeExampleNotes
nilnilUnset / absent value. Falsy.
booleantrue, falseOnly false and nil are falsy. 0 is truthy.
number3.14, 1e3Always float internally.
string"hello", 'world'Escapes: \n \t \\ \" \'
table{1, 2, 3}Ordered dict. Array indices are 1-based.
functionfunction(x) ... endFirst-class, closures supported.
Vector3Vector3(x,y,z)Fields: .x .y .z. Arithmetic works.
ColorColor(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
Arrays are 1-indexed. t[1] is the first element. t[0] returns nil.

Comments

-- single-line comment

--[[
  multi-line block comment
]]
Language

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
While loops have a 10,000 iteration hard cap. For long-running gameplay loops, use 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)
}
Always call 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
Language

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
Avoid 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.
Language

Operators

Arithmetic

OpDescriptionExample
+Add (numbers or Vector3+Vector3)3 + 4 → 7
-Subtract / unary negate10 - 3 → 7
*Multiply (number×number or Vector3×number)v * 2
/Divide (float)7 / 2 → 3.5
//Integer (floor) divide7 // 2 → 3
%Modulo7 % 3 → 1
^Power2 ^ 8 → 256

Comparison & Logic

OpDescription
==Equal
~=Not equal (Lua style — not !=)
< <= > >=Numeric comparison
andLogical and (returns value, not bool)
orLogical or (returns value, not bool)
notLogical not

String

OpDescriptionExample
..Concatenation"hi" .. " there" → "hi there"
#Length (string or table)#"hello" → 5

Precedence (highest → lowest)

^
* / // %
+ -
..
< <= > >= == ~=
and
or
API Reference

Global Functions

Available in every VXLua script without any prefix.

wait(seconds: number) → suspends

Suspends the script for seconds seconds, then resumes automatically. Mandatory inside any loop { }.

wait(1)       -- pause 1 second
wait(0.016)   -- ~60fps tick
part(name: string)→ PartProxy

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)
sound(name: string)→ SoundProxy

Finds a Sound object by name. Returns nil if not found or if the node isn't a sound asset.

print(...)→ nil

Logs a message to the Builder Output panel. Multiple arguments are joined with spaces.

part_exists(name: string)→ boolean

Returns true if a part with the given name exists in the world.

find_parts_with_prefix(prefix: string)→ table of PartProxy

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
tostring(v)→ string

Converts any value to its string representation.

tonumber(v)→ number

Parses a string or number to a float. Returns 0.0 on failure.

type(v)→ string

Returns the type name: "nil", "boolean", "number", "string", "table", "function", "Vector3", "Color", or "userdata".

time()→ number

Returns Unix timestamp in seconds. Useful for computing real elapsed time.

time_ms()→ number

Returns engine ticks in milliseconds since launch. Higher precision than time().

color_lerp(a: Color, b: Color, t: number)→ Color

Linearly interpolates between two colors. t is clamped to [0, 1].

color_from_hsv(h, s, v [, a])→ Color

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)
}
API Reference

Part API

A PartProxy is the primary object for interacting with parts. Obtain one via part("Name") or script.parent.

Properties

PropertyTypeDescription
namestringThe part's node name. Read-only.
positionVector3R/W — Global position. Assign to move the part instantly.
rotationVector3R/W — Rotation in degrees (Euler). Assign to rotate.
sizeVector3R/W — Mesh dimensions. Assign to resize.
visiblebooleanR/W — Whether the part is visible.
colorColorR/W — Albedo color of the mesh material.
anchoredbooleanWhether the part has physics anchoring. Read-only.
parentPartProxy?The parent node as a PartProxy, or nil if none.
Auto-apply: Assigning to a proxy property (e.g. p.position = Vector3(0,5,0)) automatically pushes the change to the Godot node.

Movement

p.move_to(pos: Vector3)

Teleports the part to pos in world space. Same as assigning p.position.

p.set_position(pos: Vector3)

Alias for move_to.

p.get_position()→ Vector3

Returns the live global position directly from the Godot node.

p.look_at_pos(target: Vector3)

Rotates the part to face target. Handles degenerate cases (target directly above/below) safely.

p.distance_to(target: Vector3 | PartProxy)→ number

Returns the distance from this part to a world-space position or to another PartProxy.

Rotation

p.spin(axis: string|Vector3, degrees: number)

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
p.set_rotation(euler: Vector3)

Sets absolute rotation in degrees.

p.get_rotation()→ Vector3

Returns current rotation in degrees (live from node).

Color

p.set_color(c: Color)

Sets the mesh material color.

p.get_color()→ Color

Returns the current color. Returns white if no material is found.

Visibility

p.show()  ·  p.hide()

Show or hide the part instantly.

p.set_visible(v: boolean)

Set visibility programmatically.

p.is_visible()→ boolean

Returns current visibility state.

Size

p.set_size(sz: Vector3)

Sets the mesh size. Works on Box, Sphere, and Cylinder meshes.

p.get_size()→ Vector3

Returns the current mesh size.

Hierarchy

p.get_parent()→ PartProxy?

Same as reading p.parent.

p.get_children()→ table of PartProxy

Returns a 1-indexed table of all selectable child parts.

p.child_count()→ number

Returns the number of selectable children.

API Reference

Sound API

Control sound objects placed in your map via sound("Name").

PropertyTypeDescription
enabledbooleanSet true to play, false to stop.
volumenumberVolume in dB. Default 0.0. Negative = quieter.
distancenumberMax audible distance in world units. Default 20.0.
Note: Assigning to any SoundProxy property automatically applies it to the underlying AudioStreamPlayer3D node.
local sfx    = sound("AmbientHum")
sfx.enabled  = true
sfx.volume   = -6    -- 6dB quieter
sfx.distance = 40   -- audible from further away
API Reference

math.*

Standard math library — call as math.functionName().

FunctionSignatureDescription
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.piconstant3.14159…
math.tauconstant6.28318… (2π)
math.hugeconstantInfinity
API Reference

string.*

FunctionSignatureDescription
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
API Reference

table.*

Remember: VXLua arrays are 1-indexed. The table.* functions handle index management for you.
table.insert(t, value)

Appends value to the end of table t.

table.remove(t, i?)→ value

Removes and returns the element at index i (default: last).

table.size(t)→ number

Returns the number of entries. Works on both array and dict tables.

table.concat(t, sep?)→ string

Joins all integer-keyed values with sep (default: empty string), in order from 1.

API Reference

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

FunctionSignatureDescription
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

NameValue
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)
API Reference

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

FieldDescription
.rRed (0–1)
.gGreen (0–1)
.bBlue (0–1)
.aAlpha (0–1)
.hHue
.sSaturation
.vValue (brightness)

Global Color Functions

FunctionDescription
color_lerp(a, b, t)Interpolate between two colors
color_from_hsv(h, s, v, a?)Create Color from HSV (all 0–1)
API Reference

scene.*

Read-only information about the current session.

MemberTypeDescription
scene.player_count()numberNumber of players in session. Returns 1 in editor playtest.
scene.gravitynumberWorld gravity constant (9.8). Read as a value: local g = scene.gravity
scene.world_name()stringCurrent map name. Returns "singleplayer" in editor playtest.
local n = scene.player_count()
print("Players: " .. tostring(n))
Patterns

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)
}
Patterns

Limits & Gotchas

Things to know before you spend 20 minutes debugging.

Instruction Limit

100,000 ops per step. A loop { } without wait() exhausts this instantly. Always place at least one wait() inside infinite loops.

While Loop Cap

10,000 iterations max for 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.