Lua API reference
Lua API reference
Every name a script can reach, grouped the way the editor's Docs tab groups them. The same table drives this page, that tab, the hover docs and autocomplete — so there is one description of each call, in one place, and it is the one you get everywhere.
Generated — do not edit by hand. Change the entry in crates/floptle-editor/src/ide.rs
and run UPDATE_DOCS=1 cargo test -p floptle-editor lua_api_reference_file.
New here? scripting.md is the guided tour — it teaches in order,
with worked examples. This page is the reference: complete, alphabetical within
each group, and meant to be searched.
Contents
- script basics — lifecycle, params, log — 144
- node — transform & body fields — 37
- node — methods & handles — 28
- vectors, directions & easing — 49
- scene lookups & raycast — 16
- references — wire nodes in the Inspector — 3
- input — keyboard & mouse — 42
- drawing — draw.* — 14
- the web — http., json. — 11
- the player's account — account.* — 13
- game UI — text, buttons & hooks — 71
- networking — net.*, synced — 38
- scenes — load, unload & persist — 6
- terrain — runtime sculpt & queries — 15
- pathfinding — nav.* — 26
- water — depth, buoyancy & ice — 6
- scatter — instanced props — 8
- 2D — sprites, sorting & the flat camera — 36
- vessels — assembly.* — 14
- the camera & the screen — 7
- physics controls — pause & step — 4
- frame cost — perf.* — 11
- accessibility — access.* — 11
- persistence — save.* — 7
- timers — after, every, tween — 4
- space — orbits & time-warp — 19
- components — getcomponent — 101
- animation — node:animator — 16
- particles — effects from script — 10
- audio — sounds & the mixer — 27
- assets — 7
- debug gizmos — 5
- lua stdlib — 43
script basics — lifecycle, params, log
access
Accessibility a game offers its players: UI text scale, a colour-vision filter, reduced motion and captions. The engine honours what it owns — text sizes go through the LAYOUT so scaling reflows, the filter is a post-chain stage, and UI transitions snap when motion is reduced. What it cannot honour for you (your camera shake) reads access.reducedMotion(). These are the PLAYER's settings, so persist them with save.*; the editor's ⚙ Settings → Accessibility drives the same values so you can try them. See docs/accessibility.md.
agent
A nav agent handle, from nav.agent(node). It walks its node along the navmesh: agent:moveTo(point) and read agent.state as it goes. Everything about it is a field or a method on this handle — there is no per-frame step to call.
agent.alive
False once the agent has been destroyed (or its node has). A handle kept in a variable answers about itself rather than pointing at whoever took its place.
agent.arrived
True once it got there. The flag to hang "and then attack / gather / open the door" off.
agent.blocked
True when it cannot get there right now: unreachable, or no progress for giveUpAfter seconds. A crowd pin clears itself; a cut-off goal does not.
agent.complete
Whether the route it is walking actually reaches the order. False means it is heading for the closest it can get, which is the right behaviour and worth being able to say out loud — "can't get there, going as near as I can".
agent.link
The name of the Nav Link being crossed right now, or nil the rest of the time. This is the hook for "play the climb animation": if agent.link == 'ladder' then ... end.
agent.linkKind
What kind of crossing it is on: 'placed' for a Nav Link node somebody put there, or 'drop' / 'jump' for one the bake worked out from the shape of the floor. nil when it is not on one. This is the animation rule — a climb and a fall are not the same move, and a level with six hundred generated drops in it needs one rule rather than six hundred names.
agent.linkProgress
How far across a link it is, 0 to 1 — nil when it is not on one. What a vault or climb animation is driven by, so the animation and the movement cannot disagree.
agent.moving
True while it still has somewhere to be — walking or crossing a link.
agent.offMesh
True when the order named a place the navmesh does not cover — as opposed to a place it cannot reach. Nearly always a Nav Mesh volume smaller than the level; ordering it somewhere else nearby will not help.
agent.pos
Where it is, in world space.
agent.remaining
How far there is left to walk, in metres, ALONG THE ROUTE rather than through the walls. The number an ETA or a progress bar wants.
agent.speed
How fast it is going along the ground, in units per second. What an idle/walk/run animation blend reads.
agent.state
'idle' | 'moving' | 'arrived' | 'blocked' | 'crossing' — and 'gone' for a handle whose agent has been destroyed. 'blocked' is the one worth acting on: it means the goal cannot be reached from here, or the unit has made no progress for giveUpAfter seconds. A unit pinned by its own crowd rests and tries again on its own; one whose goal is genuinely cut off stays blocked until something changes. A unit standing still with no explanation is the commonest "the pathfinding is broken" report there is, and this is the explanation.
agent.target
Where it was told to go, or nil if it has no order.
agent.velocity
How fast it is going, as a vec3. With drive = 'none' this is the whole point of the agent: it steers, and your script decides what that means for a vehicle, a boat or an animation.
agent:corners
agent:corners() — the corners still to walk, as a list of vec3 in world space. For drawing the route while working out why a unit went the way it did.
agent:destroy
agent:destroy() — take it out of the crowd. Not required (an agent whose node is destroyed goes with it on the next frame) but the right thing to call from a script's own teardown.
agent:moveTo
agent:moveTo(point) — send it to a world point (a vec3, anything with x/y/z, or a node). Idempotent: ordering it to where it is already heading costs nothing, so calling this every frame to follow a moving target is fine. The point does not have to be exactly on the navmesh — it is snapped — and a point that is on the mesh but cut off makes the agent walk as near as it can and then report blocked.
agent:set
agent:set{ ... } — change how it walks, mid-game. Takes the same options as nav.agent; anything left out is left alone. Slowing a unit down, making it stop giving way, or swapping its filter when it picks up a boat.
agent:stop
agent:stop() — cancel the order and stand still. Anything mid-crossing finishes the crossing first: halfway up a ladder is not a place to be left.
agent:teleport
agent:teleport(point) — put it AND its node somewhere without walking there, and forget what it was doing. For spawns, respawns and cutscenes. (With drive = 'none' the engine leaves the node alone, so move it yourself.)
app
The settings a game offers the person playing it, and the one thing every main menu needs: quit. app.quit(), app.title(), app.version(), and the video settings — app.vsync/setVsync, app.retro/setRetro, app.retroHeight/setRetroHeight, app.retroIntegerScale/setRetroIntegerScale. A setting you change here is for THIS SESSION: it lives in project.ron, the file that ships to every player, so Stop puts it back — persist the player's choice yourself with save., the same rule access. follows. Window resolution and fullscreen are NOT here yet; that is one question (windowing) waiting to be answered properly. See docs/scripting.md §30.
app.fullscreen
app.fullscreen() → whether the game's window covers the screen. The real state, not the last thing asked for — so a Video tab shows what the player sees, including after F11 or Alt+Enter, which a build answers on its own.
app.quit
app.quit() — end the game. What that means depends on where it is running, and they are genuinely different things: an EXPORTED BUILD closes (your save.* data is flushed first, since somebody quitting from a settings menu expects the setting they just changed to have been kept); the EDITOR stops Play and says so in the Console, deliberately NOT a process exit — an editor that closed because a game under test called quit would take your unsaved work with it; floptle run ends the run where it stands and reports that it stopped early.
app.retro
app.retro() → whether the retro presentation is on: the game composites at a small internal resolution and upscales, which is what gives it chunky pixels.
app.retroHeight
app.retroHeight() → the internal height the game composites at, in pixels. In a pixel-art game this IS the resolution setting a player means — the window size is a separate question the engine does not answer yet.
app.retroIntegerScale
app.retroIntegerScale() → whether the retro composite is upscaled by a WHOLE number and letterboxed rather than stretched to fill.
app.setFullscreen
app.setFullscreen(true) — cover the screen (borderless, on the monitor the window is on; no mode switch), or false to go back to a window. In an EXPORTED BUILD it applies this frame. In the EDITOR the window is the editor's, so it is left alone and the Console says so once — the same honesty app.quit() has about where it is running. For this session only; persist it with save.*.
app.setRetro
app.setRetro(false) — turn the retro presentation off (or on), so the game composites at the window's own resolution instead. For this session only; persist it with save.*.
app.setRetroHeight
app.setRetroHeight(360) — set the internal height the game composites at, 32–4320 pixels. Outside that range RAISES rather than clamping, the way access.setTextScale does: a settings slider hands over a number it already bounded. For this session only; persist it with save.*.
app.setRetroIntegerScale
app.setRetroIntegerScale(true) — upscale the retro composite by a whole number and centre it, letterboxing the remainder. A fractional upscale puts some source rows on two screen pixels and some on three, which on pixel art with a small font is the difference between crisp and mush. For this session only; persist it with save.*.
app.setVsync
app.setVsync("Adaptive") — set the frame pacing. "On" is classic vsync; "Adaptive" renders freely and lets the display take the newest frame; "Off" presents the instant a frame is ready, tearing and all. A name it does not recognise RAISES and lists the three — a control that silently kept the old value is a control that appears to work. For this session only: Stop puts the project back, so persist the choice with save.*.
app.title
app.title() — the game's title, for a menu to put at the top of itself. The export manifest's title in a build, the project's otherwise, and the project folder's name when neither is set — the same chain the window title uses, so a menu and the title bar never disagree.
app.version
app.version() — the engine version this build was made with, e.g. "0.81.0". For an about box, and for a bug report to quote.
app.vsync
app.vsync() → the current frame pacing as a name: "On", "Adaptive" or "Off" — the same names project.ron uses, so a Video tab can show what is set and save it straight back.
createNode
createNode(name [, parent] [, fn]) — create a PLAIN node (Empty matter). It does NOT return the node: the create is queued and the node is made after this pass, so the handle arrives ONLY through the callback — local n = createNode("Stain") gives you something that says so the moment you touch it, rather than a nil that fails a line later. fn(n) gets its handle: combine with n:setTerrain(id) / n:setCelestial{...} / n:setPrimitive(shape, color) / n:setMaterial{...} + transform writes to build content from script (procgen, editor actions). Nested creates inside callbacks are fine.
defaults
defaults = { name = value } — tunables shown in the Inspector.
defaults = {
--@header Movement
-- How fast you walk on flat ground.
--@range 0 20 --@units m/s
walk = 4.5,
--@options Off|On|Auto
assist = 1,
invert = false,
}
destroy
destroy(node) — remove a node AND its whole subtree (physics body included). Queued: applied after the pass, so the handle stays readable through the current call. Method form: node:destroy(). On a client, replicated nodes refuse (server authority — net.despawn).
dt
Seconds since the last frame (number).
fixedUpdate
function fixedUpdate(node, dt) — runs every GAMEPLAY TICK (60 Hz, constant dt). Movement/gameplay/physics writes belong here; cameras & followers in lateUpdate; other cosmetics in update. Same cadence physics steps at — frame-rate independent.
-- gameplay writes belong on the tick, not the frame
function fixedUpdate(node, dt)
node.vel = node.vel + vec3(0, -9.8, 0) * dt
end
function
Define a function.
http
Talk to a web server: http.get / post / put / delete, plus json.*. Every call is asynchronous — the reply arrives in a callback, never as a return value.
json
json.encode(t) and json.decode(s) — the wire format for http.*. decode returns nil, message on bad input rather than raising, because a reply from someone else's server is data, not a bug in your script.
lateUpdate
function lateUpdate(node, dt) — runs once per frame AFTER physics and the interpolated transform writeback: the CAMERA pass. Anything that follows something else (orbit cameras, name tags, listeners) belongs here so it samples this frame's FINAL poses. Following from update reads LAST frame's pose — a velocity × dt lag that turns frame-time noise into visible jitter.
-- follow AFTER physics, so the camera samples this frame's final pose
function lateUpdate(node, dt)
local t = find("Player")
node.pos = t.pos + t.forward * -6 + vec3(0, 2, 0)
end
local
Declare a local variable.
log
log("message") — print to the engine console.
node
The node's transform: x/y/z, scale, scale_x/y/z, yaw/pitch/roll.
obstacle
A navmesh obstacle handle, from nav.obstacle(centre, size). Fields: id, active, position, size (the hole ACTUALLY cut, grown out to whole cells). One method: ob:remove(), which gives the ground back and returns false if it had already gone.
obstacle:remove
obstacle:remove() — give the ground back where this obstacle was, and return whether it was still there. Everything that gave up on a route through it is un-blocked, so a unit that stopped beside the crate starts walking again. Calling it twice is false, not an error.
onCollisionEnter
function onCollisionEnter(node, other, hit) — fires the tick this node's body STARTS touching something solid (a collider or another body). other = the other node's handle (check other:hasTag("...") / other.name); hit = { x, y, z, nx, ny, nz } (world contact point + normal). Also onCollisionStay (every tick while touching) and onCollisionExit (on separation).
function onCollisionEnter(node, other)
if other:hasTag("hazard") then hp = hp - 10 end
end
onCollisionExit
function onCollisionExit(node, other, hit) — fires the tick the touch ends (hit = the last known contact).
onTriggerEnter
function onTriggerEnter(node, other, hit) — fires the tick a body enters a TRIGGER (the "trigger" switch on a Collider or Rigidbody: it stops blocking, events still fire — a Kinematic trigger rigidbody = a moving pickup). The portal/pickup/checkpoint hook — pair with a string param: scene.load(params.destination). Also onTriggerStay / onTriggerExit.
onTriggerExit
function onTriggerExit(node, other, hit) — fires the tick a body leaves the trigger.
params
This instance's tunables, a table seeded from defaults (params.speed, …). NUMBERS and STRINGS both work — a string default (destination = "arena") becomes an Inspector text field, so two portals can share one script with different destinations. TWO-WAY: writing a declared key persists across frames, shows live in the Inspector during Play, and is readable by other scripts through a handle (Stop reverts it). Undeclared keys stay frame-local; reference params (noderef & friends) never round-trip.
function update(node, dt)
node.x = node.x + params.walk * dt -- Inspector-tuned
end
perf
Where YOUR frame time goes — per subsystem and per script, readable from Lua so a game can assert its own budget in a smoke test rather than filing an engine ticket. Off by default and free while off: call perf.enable(true) first. Every getter RAISES while collection is off rather than answering 0, because a budget assertion that passes on no data is worse than no assertion.
spawn
spawn(prefab [, pos [, fn]]) — spawn a PREFAB instance (make one by dragging a node into the Assets panel). "bullet" finds prefabs/bullet.prefab.ron. pos = a vec3/node for the root; fn(root) runs with the new node's handle the same frame. It does NOT return the node — the spawn is queued and the node is made after this pass, so the handle only ever arrives through the callback; local b = spawn("bullet") then tells you so if you touch it — spawn("bullet", node.pos + dir, function(b) b.vx = dir.x * 40 end). Local-only in multiplayer: the server uses net.spawn for replicated objects.
local b = spawn("Bullet", node.pos + node.forward * 1.5, function(n)
n.vel = node.forward * 40
end)
start
function start(node) — runs once when play begins.
steam
Steam integration: identity, app/build info, and steam.available() for branching. Always present so steam.available() is always safe to call — nil (not an error) is what every other steam.* getter answers when it's false. See the Steam integration plan.
steam.achievementDescription
steam.achievementDescription(id) — id's display description, in Steam's own current language.
steam.achievementGlobalPercent
steam.achievementGlobalPercent(id) — the percentage of players globally who've unlocked id, once Steam has it cached; nil before then.
steam.achievementName
steam.achievementName(id) — id's display name, in Steam's own current language.
steam.achievementUnlocked
steam.achievementUnlocked(id) — true/false, or nil if stats aren't ready or id isn't a real achievement (check it against the Steamworks App Admin — a mistyped id is the single most common cause).
steam.available
steam.available() — true only for a floptle run/exported/served session with a real Steam client initialized. false in the editor's own docked Play-mode viewport, in every other session, and whenever no Steam client is running — branch on this before any other steam.* call, none of which raise when it's false (they answer nil).
steam.betaName
steam.betaName() — the beta branch this build was installed from. nil on the default branch, and nil when steam.available() is false.
steam.buildId
steam.buildId() — this build's Steam build id. nil when steam.available() is false.
steam.clearAchievement
steam.clearAchievement(id) -> ok, err — resets id to locked, locally. Same batching as steam.unlockAchievement. Refuses: the achievement id is not one this app declares on Steamworks; Steam isn't available in this session (no client running, or a session that never had one).
steam.clearRichPresence
steam.clearRichPresence() — clears every rich-presence key set via steam.setRichPresence.
steam.cloudDelete
steam.cloudDelete(name) -> ok, err — deletes name locally AND remotely. Refuses: no file of that name in this app's Cloud; Cloud is off for the app or for the player; Steam isn't available in this session (no client running, or a session that never had one).
steam.cloudEnabled
steam.cloudEnabled() — whether Cloud is enabled for THIS app specifically (independent of the account-wide setting). nil when steam.available() is false.
steam.cloudEnabledForAccount
steam.cloudEnabledForAccount() — whether Cloud is enabled account-wide. Read-only: a player controls this from the Steam client itself, not from inside a game.
steam.cloudFileExists
steam.cloudFileExists(name) — whether name exists in Cloud storage. It needn't exist to be named in any other steam.cloud* call — steam.cloudWrite creates it.
steam.cloudFileTimestamp
steam.cloudFileTimestamp(name) — name's last-write time (Unix seconds), or nil if it doesn't exist. Compare against your own local save's modification time to build your own conflict policy — Steam Cloud has no built-in one to expose.
steam.cloudFiles
steam.cloudFiles() — every file in Cloud storage for this app, as a list of { name, size } tables. nil when steam.available() is false.
steam.cloudForget
steam.cloudForget(name) -> ok, err — deletes name from the Cloud while keeping the local copy, for a player who wants this one save to stop syncing without losing it. Refuses: no file of that name in this app's Cloud; Steam isn't available in this session (no client running, or a session that never had one).
steam.cloudRead
steam.cloudRead(name) -> data, err — reads name's full contents (a binary-safe Lua string), or nil, message on failure (not in Cloud storage, most commonly). Refuses: no file of that name in this app's Cloud; Cloud is off for the app or for the player; Steam isn't available in this session (no client running, or a session that never had one).
steam.cloudWrite
steam.cloudWrite(name, data) -> ok, err — writes data (a binary-safe Lua string) as name's full contents, replacing whatever was there and creating the file if it didn't exist. Refuses: the write would put this app over the player's Cloud quota; Cloud is off for the app or for the player; Steam isn't available in this session (no client running, or a session that never had one).
steam.createLobby
steam.createLobby(opts, cb) — creates a lobby and puts you in it. A lobby is DISCOVERY, not transport: it's how players find each other and agree what they're about to play, and it decides nothing about how the game's packets travel. opts (optional): kind = "public" (default; anyone can find it), "friendsOnly", "private" (invite only) or "invisible" (joinable by id but never returned by a search); maxMembers = 8 by default, 1 to 250. cb(lobby, err) on a later frame, exactly once — lobby is { id, memberCount, memberLimit, owner, data }.
steam.downloadScores
steam.downloadScores(boardId, opts, cb) — downloads leaderboard rows. opts (optional): scope = "global" (default, ranks from the top), "friends" (only your friends) or "aroundUser" (ranks RELATIVE to your own — start = -4 with count = 9 gives you plus the four either side); start (default 1) and count (default 10). cb(rows, err) with rows a list of { userId, rank, score, details }; userId is a STRING and details is an empty list when none was uploaded.
steam.findLeaderboard
steam.findLeaderboard(name, cb) — looks a leaderboard up by name. cb(board, err) runs on a LATER frame, exactly once, in every session — including when there is no Steam at all, where err says so. board is { id, name, entryCount, sort, display }, or nil with NO err when there simply is no board by that name (a normal answer to branch on, not a failure). board.id is a STRING and lasts only this session — find the board again next run rather than saving it.
steam.findLobbies
steam.findLobbies(opts, cb) — searches for lobbies. opts (optional, all additive): match = a table of lobby data that must be equal (a string compares as a string, a whole number as a number); compare = numeric comparisons, each an operator and a value as in { skill = { ">=", 500 } } — takes ==, ~=, >, >=, <, <=; openSlots = only lobbies with at least this many free seats; distance = "close", "default", "far" or "worldwide"; maxResults. cb(lobbies, err) with a list of lobby tables — an empty list means nothing matched, which is not an error.
steam.findOrCreateLeaderboard
steam.findOrCreateLeaderboard(name, opts, cb) — like steam.findLeaderboard, but creates the board if it doesn't exist. opts (optional): sort = "descending" (default; higher is better) or "ascending" (lower is better, for times); display = "numeric" (default), "seconds" or "milliseconds". Creating boards this way is a development convenience — a shipping game's boards are normally declared on the Steamworks admin site, where they can also be reset and moderated.
steam.flushStats
steam.flushStats() — sends every pending achievement/stat write to Steam now, instead of waiting for the automatic batch (every 5s while something's pending). Safe to call with nothing pending.
steam.friendRichPresence
steam.friendRichPresence(id, key) — reads one of a FRIEND's own rich-presence values (id from steam.friends(), a string) — different from your own, which you set with steam.setRichPresence. nil if they haven't set it or aren't reachable.
steam.friends
steam.friends() — the local user's friend list, as a list of { id, name, state, playingThisGame } tables. id is a STRING (a SteamID64 exceeds what an f64 represents exactly). state is one of "online"/"away"/"busy"/"snooze"/"looking to trade"/"looking to play"/"invisible"/"offline". nil when steam.available() is false. Group/clan membership isn't exposed — Steam doesn't wrap that.
steam.installDir
steam.installDir() — this app's install directory, as Steam reports it. nil when steam.available() is false.
steam.isBigPictureMode
steam.isBigPictureMode() — true if Steam's own full-screen "10-foot" mode is active. nil when steam.available() is false.
steam.isCybercafe
steam.isCybercafe() — true if Steam has flagged this as a cybercafe/shared-computer license. nil when steam.available() is false.
steam.isFamilyShared
steam.isFamilyShared() — true if this app is being played on a license borrowed from another account (Steam Family Sharing), not one the signed-in user owns. nil when steam.available() is false.
steam.isSteamDeck
steam.isSteamDeck() — true if this session is running on Steam's own handheld hardware. Assume no physical keyboard/mouse when true. nil when steam.available() is false.
steam.joinLobby
steam.joinLobby(id, cb) — joins a lobby by id (a string). cb(lobby, err) exactly once, on a later frame. When it fails, err is a real reason where Steam gives one — "that lobby is full", "you're banned from that lobby", "that lobby no longer exists" — worth showing the player as-is.
steam.leaderboardsInFlight
steam.leaderboardsInFlight() — how many leaderboard requests have been made and not yet called back. What a "loading scores…" spinner hangs off. Pressing Stop drops every pending callback, so this returns to 0.
steam.leaveLobby
steam.leaveLobby(id) — leaves a lobby. Safe to call when you aren't in it, and safe when there's no Steam; never raises.
steam.lobbiesInFlight
steam.lobbiesInFlight() — how many lobby requests have been made and not yet called back. Pressing Stop drops every pending callback, so this returns to 0.
steam.lobbyData
steam.lobbyData(id, key) — one of the lobby's own data values, or nil. Called with NO key, steam.lobbyData(id) answers the whole table at once, which is what a lobby browser wants. nil when there's no Steam.
steam.lobbyMemberData
steam.lobbyMemberData(id, memberId, key) — one of THAT member's own values in this lobby (their character, their ready flag), or nil. Steam cannot tell a key set to "" from one never set, so both answer nil.
steam.lobbyMemberLimit
steam.lobbyMemberLimit(id) — the most members the lobby will hold, or nil.
steam.lobbyMembers
steam.lobbyMembers(id) — everyone currently in the lobby, as a list of id STRINGS (a Steam id exceeds what an f64 represents exactly). nil when there's no Steam.
steam.lobbyOwner
steam.lobbyOwner(id) — the lobby's owner (the host) as an id string, or nil. Only the owner may change lobby data or open and close the lobby.
steam.localUserId
steam.localUserId() — the signed-in local user's SteamID64, as a STRING (it exceeds what an f64 represents exactly). nil when steam.available() is false.
steam.onLobbyEvent
steam.onLobbyEvent(fn) — fires for each thing that happens in a lobby you're in. e.kind is "member" — with e.user and e.change of "entered", "left", "disconnected", "kicked" or "banned" — or "data", with e.whose telling you whether the LOBBY's data or a MEMBER's changed, so re-read the right one. Who did the kicking is deliberately not reported: Steam's binding fills that field from the wrong id. One handler; registering again replaces it, and Stop clears it.
steam.onOverlayChanged
steam.onOverlayChanged(fn) — fn(active) runs once per open (true) and close (false) of the overlay, in order, on the frame after it happened. Pause a single-player game on true and resume on false; a networked session keeps running regardless. One handler; registering again replaces it, and Stop clears it.
steam.onPersonaChanged
steam.onPersonaChanged(fn) — fires once when the local user's persona (name or avatar) changes. Re-read steam.personaName() from inside it; avatars aren't exposed to Lua yet (no engine primitive turns raw bytes into a drawable texture at runtime — see the Steam integration plan).
steam.openInviteDialog
steam.openInviteDialog(lobbyId) -> ok, err — opens Steam's invite-friends dialog for a lobby you're in (the id from steam.createLobby / joinLobby). Friends who accept still need YOUR lobby screen to bring them in — put the join in steam.onLobbyEvent. (false, why) when the overlay can't open: show the lobby code instead. Refuses: the id isn't a lobby id — pass the id from a lobby table, as a string; the overlay cannot open; Steam isn't available in this session (no client running, or a session that never had one).
steam.openOverlay
steam.openOverlay(page) -> ok, err — opens one of the overlay's own pages: "friends", "community", "players", "settings", "officialgamegroup", "stats" or "achievements". A misspelt page is refused with the list, in EVERY session — you don't need Steam running to find the typo. (false, why) when the overlay can't open, where Steam's own call would silently do nothing. Refuses: the page name is not one of the seven listed — refused with the list in EVERY session, so a typo is found without Steam running; the overlay cannot open (it is switched off in Steam's settings, or this renderer is not hooked into it); Steam isn't available in this session (no client running, or a session that never had one).
steam.openOverlayStore
steam.openOverlayStore([appId]) -> ok, err — opens a store page in the overlay: your own game's with no argument, another app's (a DLC's) with its id. When it can't (false, why): this is the purchase flow to degrade rather than break — tell the player where to look. Refuses: the app id is not a positive number; the overlay cannot open; Steam isn't available in this session (no client running, or a session that never had one).
steam.openOverlayUrl
steam.openOverlayUrl(url) -> ok, err — opens the overlay's web browser at a full http:// or https:// URL. When it can't (false, why): show the URL on screen instead, so the player can still get there. Refuses: the URL is not http(s); the overlay cannot open; Steam isn't available in this session (no client running, or a session that never had one).
steam.openOverlayUser
steam.openOverlayUser(dialog, userId) -> ok, err — opens a page about one user (their id as a string, e.g. from steam.friends()): "steamid" is their profile; also "chat", "jointrade", "stats", "achievements", "friendadd", "friendremove", "friendrequestaccept", "friendrequestignore". Same refusals as steam.openOverlay. Refuses: the dialog name is not one Steam knows; the user id is not a number in a string; the overlay cannot open; Steam isn't available in this session (no client running, or a session that never had one).
steam.overlayActive
steam.overlayActive() — true while the overlay is being shown over the game (Shift+Tab, or one of your own opens). nil when steam.available() is false — which is falsy, so if steam.overlayActive() then is safe in every session. The engine already feeds scripts neutral input while it's up; use this (or steam.onOverlayChanged) to pause a single-player game.
steam.overlayEnabled
steam.overlayEnabled() — true once the Steam overlay has hooked this game and can open; false while it's still attaching at startup, when the player has it disabled in Steam's settings, or on a setup where it can't inject (some Linux/Proton configurations). nil when steam.available() is false. Every steam.openOverlay* call answers (false, why) in the same situations, so you rarely need to poll this yourself.
steam.personaName
steam.personaName() — the local user's current display name. nil when steam.available() is false.
steam.resetAllStats
steam.resetAllStats(achievementsToo) -> ok, err — wipes every stat, and every achievement if achievementsToo. Development/QA only — never call this from a shipping build's own normal logic. Refuses: Steam isn't available in this session (no client running, or a session that never had one).
steam.setCloudEnabled
steam.setCloudEnabled(enabled) -> ok, err — toggles steam.cloudEnabled(). Refuses: the player has turned Cloud off for this app in their own Steam settings, which no game may override; Steam isn't available in this session (no client running, or a session that never had one).
steam.setLobbyData
steam.setLobbyData(id, key, value) -> ok, err — sets one of the lobby's own data values; this is what a lobby search matches against. Passing NO value deletes the key. ONLY THE OWNER may change lobby data, and err says so rather than failing quietly. Refuses: the id isn't a lobby id — pass the id from a lobby table, as a string; you are not the lobby's OWNER (only the owner may write lobby data); Steam isn't available in this session (no client running, or a session that never had one).
steam.setLobbyJoinable
steam.setLobbyJoinable(id, joinable) -> ok, err — opens or closes the lobby to new members; close it when the match starts. Owner only. Refuses: the id isn't a lobby id — pass the id from a lobby table, as a string; you are not the lobby's OWNER; Steam isn't available in this session (no client running, or a session that never had one).
steam.setLobbyMemberData
steam.setLobbyMemberData(id, key, value) -> ok, err — sets one of YOUR OWN values in this lobby. Any member may set their own, unlike steam.setLobbyData which is owner-only. Refuses: the id isn't a lobby id — pass the id from a lobby table, as a string; you are not IN that lobby; Steam isn't available in this session (no client running, or a session that never had one).
steam.setRichPresence
steam.setRichPresence(key, value) -> ok, err — sets a rich-presence key for the local user, visible to friends in their friend list. Steam caps the number of keys and their length; err names the reason, not a bare failure. Refuses: more than 20 keys set, or a key or value past Steam's length limit (the whole set must fit in 8 KB); Steam isn't available in this session (no client running, or a session that never had one).
steam.setStatFloat
steam.setStatFloat(name, value) -> ok, err — writes a float stat LOCALLY. Same batching as steam.unlockAchievement. Refuses: the stat id is not one this app declares on Steamworks, or it is declared as an integer; Steam isn't available in this session (no client running, or a session that never had one).
steam.setStatInt
steam.setStatInt(name, value) -> ok, err — writes an integer stat LOCALLY. Same batching as steam.unlockAchievement. Refuses: the stat id is not one this app declares on Steamworks, or it is declared as a float; Steam isn't available in this session (no client running, or a session that never had one).
steam.statFloat
steam.statFloat(name) — a float stat's current value, or nil.
steam.statInt
steam.statInt(name) — an integer stat's current value, or nil before stats are ready / if name isn't real.
steam.statsReady
steam.statsReady() — true once achievements/stats have finished loading from Steam. Every achievement/stat call below answers nil (reads) or false with a message (writes) before this, rather than guessing.
steam.uiLanguage
steam.uiLanguage() — Steam's own UI language right now (e.g. "english", "french") — a reasonable default for your own localization. nil when steam.available() is false.
steam.unlockAchievement
steam.unlockAchievement(id) -> ok, err — unlocks LOCALLY (cheap, in-memory); reaches Steam's server and triggers its own unlock notification on the next automatic batch or steam.flushStats(). err is nil on success, an actionable message (e.g. an unknown id) otherwise. Refuses: the achievement id is not one this app declares on Steamworks; Steam isn't available in this session (no client running, or a session that never had one).
steam.uploadScore
steam.uploadScore(boardId, score, opts, cb) — uploads a score for the local user. opts (optional): method = "keepBest" (default — keeps whichever score ranks better) or "forceUpdate" (overwrite even with a worse one, for a "most recent run" board); details = a list of whole numbers stored alongside the score (ghost data, a replay seed), up to 64. cb(res, err) with res = { score, changed, rank, previousRank } — score is what is STORED, which under keepBest is not necessarily what you uploaded, and changed is how a "new personal best!" banner knows.
time
Seconds since play started (number).
ui
Screen UI from scripts: ui.on / ui.events for input, ui.bind for data, ui.make for whole trees. See the game-UI section for the full set.
update
function update(node, dt) — runs every frame while playing.
function update(node, dt)
node.yaw = node.yaw + math.rad(90) * dt
end
voice
Proximity voice chat: the microphone, and every remote player's voice as an ordinary spatial sound. A remote speaker plays through a mixer track like anything else, so a track carrying PitchShift + Distortion + Reverb turns the killer into a monster with no new audio API. Frames are Opus (48 kHz, 20 ms, ~24 kbps mono) on unreliable datagrams: loss shows as a gap the decoder conceals, never as a stall. WHO HEARS WHOM IS THE SERVER'S DECISION (voice.setForward) — attenuating a stream a client already received is a volume slider a modified client turns back up.
voice.attach
voice.attach(peer, node, { mode = "Spatial", falloff = "Inverse", minDistance = 2, maxDistance = 22, track = "Voice" }) — a remote player's voice comes out of that node and follows it. The same knob set audio.play takes, because a remote speaker IS an ordinary spatial sound. Survives scene.load: the stream lives with the SESSION, so a server switching maps does not cut anyone off mid-sentence — re-attach in the new scene and the stream never restarted.
voice.detach
voice.detach(peer) — the voice stops following a node and goes back to being unpositioned. The stream keeps running.
voice.device
voice.device() — the input device currently open, or nil.
voice.devices
voice.devices() — input device names, for a settings screen. EMPTY on a machine with no microphone, which is most machines and is not an error: every other voice call goes on working as a no-op.
voice.level
voice.level() — the local microphone's level, 0..1, for a settings-screen meter. LIVE WHETHER OR NOT TRANSMIT IS ON, which is the point: a player can prove their mic works without joining a lobby to find out. 0 with no device.
voice.mute
voice.mute(peer, on) — a LOCAL mute: one player's choice not to listen, which never leaves this machine. It stops the decode as well as the sound, so a muted speaker costs nothing.
voice.muted
voice.muted(peer) — has this machine muted that peer locally?
voice.setDevice
voice.setDevice(name) — open that input device; voice.setDevice(nil) opens the system default. A device that isn't there is one Console line, not a crash.
voice.setForward
SERVER ONLY: voice.setForward(peer, { peers }) — who may hear that speaker; nil for everyone. THIS IS WHERE PROXIMITY VOICE IS ENFORCED. A game using it sets this from distance every tick or so, and a peer not on the list is never sent the audio at all — because attenuating a stream a client already received is a volume slider a modified client turns back up, and in a hidden-role game hearing someone is knowing where they are.
voice.setTransmit
voice.setTransmit(on) — open or close the microphone. Push-to-talk is your decision (voice.setTransmit(input.action("Talk"))); this is where it takes effect. The gate is on the CAPTURE side, so nothing further down the path can leak a live mic into a lobby, and releasing it discards the half-built frame rather than splicing two moments together.
voice.sidetone
voice.sidetone(on) — hear your own microphone. Off by default because it is disconcerting; it plays the raw capture rather than the encoded copy, so it confirms the mic is live without the codec's delay.
voice.source
voice.source(peer) — a handle shaped like the one audio.play returns: :setTrack, :setVolume, :setPosition(node), :setMode, :setFalloff, :setMinDistance, :setMaxDistance. .live says whether that stream exists at all, so a game can tell "quiet" from "not in this session". Moving a peer between mixer tracks is how a voice becomes a monster: voice.source(peer):setTrack("Voice Monster").
voice.speaking
voice.speaking(peer) — true while that peer's frames are arriving, for a HUD indicator. Holds for a moment past the last frame so it does not flicker between syllables.
voice.transmitting
voice.transmitting() — is the microphone open right now?
node — transform & body fields
node.forward
The node's facing as a vec3, from its rotation (-Z forward, matching the camera). Works on anything with a transform, body or not.
-- facing, from the node's rotation: -Z forward, +X right
local aim = node.forward
if raycast(node.pos, aim, 50) then log("something ahead") end
node.groundNormal
The floor the body is standing on, as a vec3 normal — nil when airborne, so it is exactly node.grounded with the surface attached. Read-only. node.groundNormal:dot(node.up) is the cosine of the slope: 1 is flat, 0.5 is 60°. Align a character to the ground, judge a landing, or refuse to walk up something too steep.
node.grounded
True while the rigidbody rests on a surface (read-only). Gate jumps on it.
node.height
Capsule standing height — write a smaller value to crouch (the engine resizes it, feet planted).
node.id
A stable numeric id for this node.
node.layer
The node's collision/query layer, by project-defined NAME ("Default" when unset). Assign to move it (node.layer = "Ghosts") — a name the project doesn't define is an ERROR, so typos surface immediately. The Project Settings matrix decides which layers collide; a dynamic body re-layers live.
node.material
Apply a material — assign a preset name ("Gold") or an assets.getFile("materials/X.ron").
node.model
A Mesh node's model path — read it, or ASSIGN it to swap the model live (e.g. node.model = assets.getFile("models/x.glb")).
node.name
The node's name (string).
node.parent
The parent node handle, or nil. A handle has the same fields (x/y/z, …) so you can read/write another node.
node.pitch
Pitch about X, in radians.
node.pos
The node's position as a vec3 (read/write): node.pos = node.pos + dir * dt. Accepts anything with x/y/z.
node.pos = node.pos + node.forward * (params.walk * dt)
node.right
The node's +X axis as a vec3 (its rotation applied). Pairs with node.forward for camera-relative movement.
node.roll
Roll about Z, in radians.
node.scale
Uniform scale (shortcut). Setting it scales all axes.
node.scale_x
Scale along X.
node.scale_y
Scale along Y.
node.scale_z
Scale along Z.
node.scripts
Every script on this node as an array of handles, in the order they were attached — possibly empty, never nil. The plural of node:getScript, for when you do not know the name yet: for _, s in ipairs(n.scripts) do print(s.kind) end says what a node actually carries, which is exactly the question a getScript that answered nil leaves you holding.
node.size
The node's whole scale as a vec3 (read/write). node.scale stays the uniform-scale shortcut, and also accepts a vec3 when you want all three axes at once.
node.tags
The node's tags as an array of strings (a fresh table each read). Assign a whole array to replace the list; use node:addTag / node:removeTag for single edits and node:hasTag to test.
node.tickPos
The body's TICK pose as a vec3 (read/write) — where the simulation says it is, as opposed to node.pos, which is the interpolated pose the camera renders. Inside fixedUpdate use this one: move with node.tickPos = node.tickPos + vec3(d, 0, 0) and build hurtboxes from it. node.x = node.x + d in fixedUpdate teleports the body onto its VISUAL position, so the model slides and the hitbox doesn't follow. In a rollback match this is the difference between a hit registering and not.
node.tickYaw
The body's tick-domain yaw (read/write) — node.yaw's simulation-truth counterpart, for facing a fighter inside fixedUpdate.
node.up
The body's up as a vec3 — minus gravity, so Y on flat ground and RADIAL on a planet. The direction to jump in, wherever the player is standing.
-- the body's up (-gravity): Y on flat ground, radial on a planet
local lean = node.up:dot(vec3(0, 1, 0))
node.up_x
Body up (−gravity) X — radial on a planet, so move along it for planet gravity. Read-only.
node.up_y
Body up (−gravity) Y (read-only).
node.up_z
Body up (−gravity) Z (read-only).
node.vel
The body's velocity as a vec3 (read/write). node.vel = node.vel + node.up * jump replaces three vx/vy/vz lines, and it accepts anything with x/y/z. A body's fields are there from the first tick its scripts run — a rig that net.spawn just handed you included; nil only on a node with no RigidBody.
-- one write instead of vx/vy/vz, and it reads as physics
if node.grounded and input.pressed("space") then
node.vel = node.vel + node.up * params.jump
end
node.visible
Whether the node's geometry is drawn — set node.visible = false to hide it (true to show).
node.vx
Rigidbody velocity X (m/s). Read + write to drive the body; the engine integrates it.
node.vy
Rigidbody velocity Y (m/s). Keep this for gravity/jump while replacing the horizontal part.
node.vz
Rigidbody velocity Z (m/s).
node.wallNormal
The steepest surface the body is pressed against, as a vec3 normal — the cliff you ran at, the crate you're shoving — or nil when there's nothing but floor. Read-only. This is what stops a controller launching itself: driving into a steep face means the solver pushes the capsule out along a normal that points partly UP, every frame, which reads as being fired into the sky. Take that component out of your movement (see first_person.lua's slide) and you slide along the face instead. Also: wall jumps, wall slides, 'you can't go that way'.
node.x
World X position (number).
node.y
World Y position (number).
node.yaw
Heading about Y, in radians.
node.z
World Z position (number).
node — methods & handles
node:addTag
node:addTag("burning") — add a tag at runtime (duplicates are ignored). findTagged sees it next frame.
node:animator
node:animator() — the animation handle for this node's Animation Controller (or a rigged model's embedded clips). Setters: :play/:restart/:crossfade/:stop/:setSpeed/:setLayerWeight/:seek. Getters: :state/:time/:finished/:isPlaying/:clips/:layers.
local anim = node:animator()
anim:crossfade(node.vel:length() > 4 and "run" or "walk", 0.15)
node:children
An array of this node's child handles.
node:destroy
node:destroy() — remove this node and its children (same as destroy(node)). The classic pickup: onTriggerEnter → award score → node:destroy().
node:find
node:find("Muzzle") — the first descendant (any depth) with that name, or nil.
node:getChild
node:getChild("Gun") — the first child with that name (a node handle), or nil.
node:getParent
The parent node handle, or nil (same as node.parent).
node:getScript
node:getScript("health") — a script handle for that script on this node, or nil. Read/write its state, call its methods, reach .node / .params.
node:hasTag
node:hasTag("enemy") — whether the node carries that exact tag. The classic hit-filter: local hit = raycast(...) if hit and hit.node and hit.node:hasTag("enemy") then ... end
node:material
node:material() / node:material("Clothing") — a material you can read AND assign, in code. With no name it is this node's OWN Material, which on a model covers every part of it. With a name — an object like "Torso#2" or a material like "Clothing", both from node:materials() — it is that part of the model alone, and the override is created the first time you write to it. Fields: texture (and normalMap/roughnessMap/metallicMap/occlusionMap) by path, color/emissive/specular/rim as colours, plus alpha, roughness, metallic, emissiveStrength, unlit, fog, cell. This is how a clothing system works: node:material("Clothing").texture = "art/shirt.png". A part's override starts as the engine's default material, not as the part's imported look — state what you want it to be.
node:materials
node:materials() — what this model's parts are CALLED, which is what you need before you can address one: a list of { object =, material =, textured =, overridden = }. object names one sub-object exactly (import renames repeats, so a model with two Torso nodes has a "Torso#2" — which is why guessing does not work); material is the glTF material name and reaches every part wearing it, usually the grouping you mean. Empty on a node that is not an imported model.
node:removeTag
node:removeTag("burning") — remove a tag (no-op when absent).
node:setCamera
node:setCamera{fovY=1.0, active=true, target="minimap", width=256, height=256, hz=10, cullMask=…} — aim a camera, hand it play-mode authority, and point it at a RENDER TARGET. With a target the camera draws the world into a live texture any material or UI image wears as "rt:" — minimaps, mirrors, security monitors, scopes, split-screen. width/height are the texture's pixels (8–4096) and hz how often it redraws (0 = every frame), so a 10 Hz minimap costs a sixth of a 60 Hz one. active=true clears every other camera's authority, because two active cameras is not a choice anyone made. fovY is RADIANS. Every value is checked at the call: an unknown key, a width=0 or an hz="10" raises naming the property, the value and the range.
node:setCelestial
node:setCelestial{mu=…, bodyRadius=…, soi=0, parent="Sun", a=…, e=…, i=…, m0=…, atmoColor={r,g,b}, atmoHeight=…, atmoDensity=…, clouds=…, luminosity=…, starColor={r,g,b}, occluderRadius=…} — set (creating if absent) the node's CelestialBody. camelCase fields; colors take {r,g,b}. occluderRadius = occlusion culling: the solid-core radius geometry never pierces — terrain chunks fully behind it skip their draw calls (keep it below the deepest cave/dig; 0 = off).
node:setGlyphOffsets
node:setGlyphOffsets{ vec2(0,0), vec2(0,-3), … } — displace individual characters at draw time, in design units, one entry per character of the authored string (shorter is fine; the rest stay still, and {} clears).
The half spans cannot do. Glyph positions are computed inside the renderer and never surface, so a game could not move one letter at any price — no wobble, no shake, no per-glyph reveal. Applied AFTER layout: a displaced glyph never re-wraps its line and never moves its neighbours, so an effect cannot reflow the sentence it is decorating.
Deliberately offsets rather than named effects: which characters move, by how much and on what phase is the game's to decide, and the engine only agrees to move a glyph it has already positioned. A shadow and an outline follow the glyph they belong to.
node:setLighting2D
node:setLighting2D{mode="2d", layers={"Terrain","Characters"}, blocks="on", inner=4, falloff=2, shadows=true} — 2D lighting, from a script. mode is auto/2d/3d and says whether this node is on the 2D lighting path at all; auto decides from the scene and is never re-decided once you say otherwise. On a LIGHT, layers is the sorting layers it reaches — empty or absent means all of them, which is how you keep a torch off the background. inner is full brightness out to that radius before the ramp starts (0 = the ramp starts at the light) and falloff is its exponent (2 = the curve every light has always had): together they let a posterized game land a whole light inside one band instead of drawing concentric rings. shadows=false makes this one light pass through everything, whatever the scene blocks. On a RECEIVER, blocks is auto/on/off for whether it occludes light — under auto a tilemap casts from the collision it already declares, so a level's collision IS its light occlusion. A bad spelling names the accepted set rather than silently meaning auto.
node:setMaterial
node:setMaterial{color={r,g,b}, emissive={r,g,b}, emissiveStrength=…, unlit=true, texture="…", alpha=…, …} — set (creating if absent) the node's Material. texture also takes a live render target: "rt:".
On a MODEL this material SUPERSEDES the ones the model was imported with — every part draws with it, textures included, so a material naming no texture draws untextured. That is what a node Material is for: "this whole thing is made of THIS". To change one part instead, use node:material("") — node:materials() lists what the parts are called.
Surface maps: normalMap / roughnessMap / metallicMap / occlusionMap (paths, "" clears) with normalStrength / roughness / metallic / occlusionStrength. shading="physical" switches from the hand-set Blinn-Phong highlight to metal-rough; roughness and metallic only mean anything there, while a normal or occlusion map works under either.
Under physical shading a surface also REFLECTS the sky. A mirror is metallic=1 with roughness=0; raise the roughness and the same reflection blurs. reflectivity scales it (1 = the real amount and the default, 0 = none, above 1 = a deliberate cheat).
fog=false exempts the surface from the scene's fog — both the distance ramp and the volumetric layer — so it draws at its own colour however far away it is. For the things that are not really in the world at that distance: a first-person weapon, a backdrop card, a marker that has to stay readable through the weather. A planet's atmosphere is a separate effect and still applies.
Retro artefacts: jitter (screen-grid vertex snapping, 0 = follow the project), affineUv, vertexLit, ditherAlpha. The project can ask for all four at once (Project Settings ⏵ Rendering); retroExempt=true takes none of them, which is how you hold a viewmodel steady in a world that wobbles.
-- setup-time; use setShaderParam for per-frame values
node:setMaterial{ unlit = true, emissive = {1, 0.45, 0.15}, emissiveStrength = 2.5 }
node:setPointLight
node:setPointLight{color={1,0.8,0.5}, intensity=2, range=8} — make this node a light, or retune one. Every field is optional and keeps what the node had, INCLUDING its emitter shape — retuning a window’s colour never turns it back into a bare point. The shape itself is set through node:getcomponent("PointLight").shape. Sixteen lights reach the shader at once; past that the ones contributing most at the camera win, and a light at intensity=0 gives its slot back — which is how you pool them. perf.counts().lights and .lightsDropped say where you stand.
node:setPrimitive
node:setPrimitive("Sphere" [, {r,g,b}]) — make the node a primitive (Cube/Sphere/Capsule/Plane).
node:setScreenShader
node:setScreenShader("inkOutline", false) — switch one of the Post Processing node's screen shaders on or off. The name is the file without its extension, the one the Inspector lists. The pass and its knobs stay in the scene, so this is a switch and not a deletion: turn the outline on for a boss fight and off again after. Pass "" for every pass on the node.
-- switch one of the scene's screen shaders on or off (it keeps its knobs)
local post = find("Post Processing")
post:setScreenShader("inkOutline", bossFight)
post:setShaderParam("inkOutline.thickness", 1 + rage * 2)
node:setShaderParam
node:setShaderParam("glow", 2.5) / node:setShaderParam("nose", x, y, z) — drive a .flsl uniform on this node every tick (a GPU uniform write, never a recompile). Targets the node's Material shader, its UI element's stage ui shader (the navball pattern: a script feeds an instrument's uniforms each tick), the Skybox's sky shader, or — on the Post Processing node — its SCREEN shaders: name one with "inkOutline.thickness", or leave the prefix off to set that knob on every pass. Unset lanes are 0. On a MODEL with per-part material overrides and no node Material it fans out to every part that wears a shader; to reach ONE part, go through its handle: node:material("Head#2"):setShaderParam(...). A write with nothing to land on is said once in the Console rather than lost silently.
-- a live uniform write: safe every tick, never recompiles
node:setShaderParam("cell", math.floor(time * 8) % 16)
node:setShaderTexture
node:setShaderTexture(slot, ref) — point one of this node's .flsl shader TEXTURE SLOTS somewhere else, at runtime. slot is the name the shader declares (texture ramp -> "ramp"); ref is a project-relative image path, an rt:<name> render target (what another camera sees, live), or "" to clear it. A shader may declare up to 8 slots, so a material can mix a base, a mask, a ramp and a screen — and a script can swap any of them per frame.
-- swap a shader's texture slot at runtime (a path, or a live render target)
node:setShaderTexture("decal", damaged and "textures/scorch.png" or "")
node:setShaderTexture("screen", "rt:securityCam")
node:setTerrain
node:setTerrain(id) — make the node a Terrain volume with that id; fill it with terrain.generatePlanet(id, opts).
node:setTerrainGen
node:setTerrainGen(opts) — attach an ON-DEMAND generation spec (the same opts table terrain.generatePlanet takes): the body's field generates in the background when something first approaches, so no field file is needed at all — a rolled galaxy is playable instantly and unvisited worlds cost one scene node. Player edits saved under terrain.saveDir take priority over regeneration. nil clears.
node:setTextSpans
node:setTextSpans{ {len=10}, {len=3, color={1,0.3,0.3}}, {len=8} } — colour stretches of this UI element's text instead of the whole string. A TextSpec carries one colour, which is a fine default and the wrong floor: the moment a game writes prose at the player it wants a proper noun in the speaker's colour, a keyword tinted to match the key it names, or an item name in its rarity. The only alternative was splitting the line into sibling elements laid out by hand, which re-wraps wrong at every resolution and cannot be revealed a glyph at a time.
Spans style; they never lay out. Wrapping, alignment, max_lines and ellipsis are computed across the whole string exactly as before, so a two-colour run wraps identically to the same string in one colour — a span boundary is not a line break a plain string would not have had. That is also why a span cannot change size or font: those WOULD move the text, and a field that quietly did nothing would be worse than its absence.
len is CHARACTERS of the authored string, not bytes, and spans run end to end from the start — a span with no color is how you spell a gap. Characters no span reaches keep the element's own colour. Pass {} to clear.
node:setTint
node:setTint(color [, alpha]) — a colour MULTIPLIED over everything this node draws, keeping its own textures and each part's own colour. The easy "same model, but red": a hit flash, a team colour, a highlighted selection, a building ghosted while it is placed, a body fading out (that is what the alpha is for). node:setTint() with no argument clears it.
node:setTint{ color =, alpha =, rim =, rimStrength =, ambient = } sets the rest. A multiply can only take light AWAY, which is why a team colour on a mid-toned model arrives as a slightly warm grey — so a tint also carries the two knobs that ADD light: rim is an additive fresnel edge in its own colour (what actually tells two team-coloured fighters apart in motion), and ambient multiplies this node's share of the scene's ambient, lifting a character out of the room's shadow. A table is read as a colour unless it carries one of those names, so setTint{1, 0.5, 0.2} is still a colour.
Every field is optional and the ones you leave out KEEP their value, so a hit flash that rewrites the colour each frame does not cost the model its rim or its ambient lift. node:setTint() with nothing takes the whole tint away.
Not a Material. A Material says what a thing is MADE OF and supersedes the materials a model was imported with; a tint leaves all of that alone. Reachable as a component too — node:getcomponent("Tint").color = color(1, 0.3, 0.3) — so an animation clip can key a flash.
node:sound
node:sound() — the handle for this node's Audio Source component. :play() (restarts), :stop(), :pause(), :resume(), :setClip("audio/x.ogg"), :seek(secs), :isPlaying(), :position(). Tunables (volume/pitch/distances/…) live on node:getcomponent("AudioSource").
node:uiRect
node:uiRect() -> x, y, w, h — where this UI element was actually laid out on screen this frame, in pixels, or nil if it is not a UI element or has not been drawn yet. The layout is the engine's, so this is the only way to find out where a Stack or a Pin put something — for a tooltip that follows a button, an arrow pointing at it, or a hit test of your own. Under floptle run there is no surface, so nothing is ever laid out and this is always nil — floptle shot is the headless way to see a screen; guard the call (if x then) in anything that also runs headless.
vectors, directions & easing
dirFromYaw
dirFromYaw(yaw [, pitch]) — the unit direction those angles face: the inverse of yawOf/pitchOf. Without a pitch you get the ground direction, which is what movement wants; with one you get a camera's view direction.
-- the yaw/pitch -> direction pair, with the right signs
local look = dirFromYaw(node.yaw, node.pitch)
node.pos = head - look * distance -- an orbit camera, in one line
dirTo
dirTo(from, to) — the UNIT direction from one thing to another. Both may be a vec3, a {x=,y=,z=} table or a NODE handle, so dirTo(node, target) is the whole sentence. Same point twice → vec3(0,0,0), never a NaN.
local aim = dirTo(node, find("Enemy"))
spawn("Bullet", node.pos + aim * 1.5, function(b) b.vel = aim * 60 end)
distance
distance(a, b) — distance between two points: vec3/vec2 values, {x=,y=,z=} tables, or NODE handles (distance(node, target) just works). Also distance(x1,y1,z1, x2,y2,z2) for raw numbers.
ease
ease(a, b, rate, dt) — frame-rate-independent exponential ease: a covers a rate-dependent FRACTION of the remaining distance each second, so 30 fps and 240 fps feel identical. Numbers or vectors. rate <= 0 snaps. This is what a camera's "smoothing" knob is; three shipped camera scripts each defined it privately before it lived here.
-- the same feel at 30 fps and at 240 (this is what "smoothing" is)
function lateUpdate(node, dt)
node.pos = ease(node.pos, target.pos + offset, params.smoothing, dt)
end
lookRotation
lookRotation(dir [, up]) -> yaw, pitch, roll — the angles that face dir, WITHOUT applying them (node:lookAt applies them). Three returns, so node.yaw, node.pitch, node.roll = lookRotation(f, up) is one line. No up = roll 0.
-- the angles, without applying them
node.yaw, node.pitch, node.roll = lookRotation(forward, node.up)
moveTowards
moveTowards(node, target, maxDelta) — walk a node toward a WORLD point at a speed, never overshooting it. Pass speed * dt. Returns true once it has arrived, so if moveTowards(node, goal, s * dt) then is the whole patrol step. Also spelled node:moveTowards(target, maxDelta).
-- a patrol, in two lines: it returns true on arrival
if node:moveTowards(waypoints[i], params.speed * dt) then
i = i % #waypoints + 1
end
node.worldPos
The node's position in WORLD space as a vec3, composed up the parent chain (read-only; node.worldX/worldY/worldZ are the components). node.x/y/z are LOCAL — comparing those against a world target is how a unit under a container walks past its destination and keeps going.
-- x/y/z are LOCAL; this is where it really is
if node.worldPos:distance(order) < params.arrive then arrived() end
node:distanceFlat
node:distanceFlat(other [, up]) — distance ignoring the up axis (default +Y): the "have I arrived?" test for anything that walks on ground it doesn't control the height of. Pass an up for a planet.
node:distanceTo
node:distanceTo(other) — distance to a node or a point, measured in WORLD space, which is the answer people mean. distance(a, b) compares LOCAL positions — correct right up until one of the two is parented, and then quietly about the wrong frame.
-- WORLD space, so a unit under a container measures the real gap
if node:distanceTo(player) < params.aggro then chase(player) end
node:lookAt
node:lookAt(target [, up]) — point this node at another node or a world point. Sets yaw + pitch and leaves roll alone; pass an up and it sets the roll too, to whatever puts that up over the node's head (a level horizon on a planet — the twenty-line undo-yaw-then-pitch dance, in one call). Measured in WORLD space on both ends.
-- point at a node or a world point; the up makes the horizon level
node:lookAt(find("Enemy"))
node:lookAt(aimPoint, node.up) -- roll set too, for a planet camera
node:moveTowards
node:moveTowards(target, maxDelta) — the method spelling of moveTowards(node, …). World-space and placed through the parent inverse, so a node under a container arrives where you actually pointed.
node:setWorldPos
node:setWorldPos(v) — put this node at a WORLD point, whatever it is parented to, without deriving the parent inverse by hand. Goes through the componentwise TRS inverse, so it stays exact under a MIRRORED (negative-scale) parent, where a matrix decomposition puts the flip on the wrong axis.
-- land on a world point whatever this node is parented to
node:setWorldPos(hit.node:toWorld(vec3(0, 1, 0)))
node:toLocal
node:toLocal(v) — the inverse of node:toWorld: a world point expressed in this node's frame.
node:toWorld
node:toWorld(v) — a point in this node's own frame, converted to world space: its position, rotation AND scale, composed up the whole parent chain. "Where is the muzzle?" is gun:toWorld(vec3(0, 0, -1.2)).
-- composes position, rotation AND scale up the whole parent chain
local muzzle = gun:toWorld(vec3(0, 0, -1.2))
spawn("Bullet", muzzle, function(b) b.vel = gun:worldForward() * 60 end)
node:turnTowards
node:turnTowards(target, maxRadians) — turn toward something by at most that much, the SHORT way round (the ±pi seam is handled). Pass rate * dt for a frame-rate-independent turn. A node handle or a world point is somewhere to face; any other vector is taken as a DIRECTION, so node:turnTowards(node.vel, 6 * dt) steers a unit to face where it is going. A zero-length direction leaves the facing alone.
-- swing round at a rate instead of snapping. Short way, always.
node:turnTowards(find("Enemy"), params.turn_rate * dt)
node:turnTowards(node.vel, 6 * dt) -- or: face where you're going
node:worldForward
node:worldForward() — the node's forward AFTER the parent chain. node.forward is the LOCAL one: a gun barrel parented to a swinging arm points where the ARM says, so shooting along node.forward misses. Also node:worldRight() and node:worldUp().
node:worldRight
node:worldRight() — the node's +X axis after the parent chain.
node:worldUp
node:worldUp() — the node's +Y axis after the parent chain (not the same as node.up, which is the body's −gravity up).
pitchOf
pitchOf(dir) — the pitch that faces along a direction, positive looking up. asin, clamped, so a denormalised vector can't produce a NaN.
smoothDamp
smoothDamp(current, target, vel, smoothTime, dt) -> value, vel — a critically-damped spring: unlike ease it has MOMENTUM, so a follow keeps moving for a moment after the target stops. Lua has no reference parameters, so the velocity comes back as the second return: camX, camVX = smoothDamp(camX, wantX, camVX, 0.25, dt). Numbers or vectors.
-- a follow with momentum: it keeps moving after the target stops
camX, camVX = smoothDamp(camX, target.worldX, camVX, 0.25, dt)
vec2
vec2(x, y) — a 2-vector value (UI/screen math), same operators and methods as vec3 (minus cross).
vec2.x
The vector's X.
vec2.y
The vector's Y.
vec2:distance
vec2:distance(other) — the distance between two 2-D points.
vec2:dot
vec2:dot(other) — the dot product; the cosine of the angle when both are unit length.
vec2:length
vec2:length() — how long the 2-D vector is.
vec2:lengthSquared
vec2:lengthSquared() — length without the square root, for comparisons.
vec2:lerp
vec2:lerp(other, t) — a straight-line blend from this (t = 0) to other (t = 1).
vec2:magnitude
vec2:magnitude() — how long the 2-D vector is. The same call as vec2:length(), under the name most engines use for it.
vec2:normalized
vec2:normalized() — a unit-length copy, pointing the same way. Zero stays zero rather than becoming a NaN.
vec3
vec3(x, y, z) — a 3-vector VALUE with real operators: a + b, a - b, v * 2, -v, a == b. Methods: :length() (:magnitude()), :lengthSquared(), :normalized(), :dot(o), :cross(o), :lerp(o, t), :distance(o), :flatten(up), :withX/:withY/:withZ(n), :rotatedY(rad), :rotatedAround(axis, rad), :towards(o, maxDelta), :angleTo(o). vec3() = zero, vec3(s) = splat, vec3(other) = copy. Anything that takes a vector also takes a {x=,y=,z=} table or a node handle. A project chooses which vec3 this is — exact (64-bit, mutable; every project made before the setting) or fast (32-bit, immutable, no allocation; new projects) — in Project Settings → Scripting → Script vec3. The surface above is identical in both; the two differences are that fast cannot be assigned into (use :withX/:withY/:withZ) and type(v) answers "vector" there rather than "userdata". floptle lint --vec3 lists what a project would change to switch.
local v = vec3(1, 0, 0) * 5 + vec3(0, 2, 0) -- real operators
log(v:length(), v:normalized(), v:dot(node.forward))
vec3.x
The vector's X. Vectors are values, not handles: nothing you do to one reaches whatever it came from. Whether you can WRITE it depends on the project's vec3 (Project Settings → Scripting → Script vec3): under exact (every project made before the setting existed) v.x = 5 changes that vector; under fast (new projects) a vector cannot be assigned into and v.x = 5 raises, naming the fix — v = v:withX(5), which works on both. Nodes are unaffected either way: node.x = 5 moves the node.
vec3.y
The vector's Y.
vec3.z
The vector's Z.
vec3:angleTo
v:angleTo(other) — the unsigned angle between two directions, in radians. Clamped before the acos, so parallel vectors give 0 and a zero vector gives 0 — never a NaN.
vec3:cross
vec3:cross(other) — a vector perpendicular to both, right-handed. The way to build a basis, or to ask which side of a plane something is on.
vec3:distance
vec3:distance(other) — the distance between two points. Reads better than (a - b):length() and does the same thing.
vec3:dot
vec3:dot(other) — the dot product. With unit vectors it is the cosine of the angle between them: node.forward:dot(toEnemy) > 0.7 is a 45° cone in front.
vec3:flatten
v:flatten(up) — the part of v that lies in the plane PERPENDICULAR to up, renormalised. THE planet-safe move: "forward along the ground" is dirFromYaw(node.yaw):flatten(node.up) whatever the local vertical is, and on a flat world :flatten() (default +Y) is the familiar "drop the Y". Straight up or down leaves nothing in the plane → vec3(0,0,0), never a NaN.
-- "forward along the ground" — on a flat world AND on a planet
local up = node.up or vec3(0, 1, 0)
local fwd = dirFromYaw(node.yaw):flatten(up)
local right = fwd:cross(up)
vec3:length
vec3:length() — how long the vector is. The distance form of a difference: (b - a):length().
vec3:lengthSquared
vec3:lengthSquared() — length without the square root. Compare distances with it (d2 < r*r) and skip the expensive part.
vec3:lerp
vec3:lerp(other, t) — a straight-line blend, t from 0 (this) to 1 (other). The one-liner behind smooth camera and marker movement.
vec3:magnitude
vec3:magnitude() — how long the vector is. The same call as vec3:length(), under the name most engines use for it — both are here so neither spelling is a dead end.
vec3:normalized
Unit-length copy (zero stays zero).
vec3:rotatedAround
v:rotatedAround(axis, rad) — Rodrigues rotation about ANY axis, which is what a planet camera's yaw actually is (about the LOCAL up, not about +Y).
vec3:rotatedY
v:rotatedY(rad) — spun about world +Y (the yaw of a flat world). For any other axis use v:rotatedAround(axis, rad).
vec3:towards
v:towards(other, maxDelta) — step toward another point without ever overshooting it: math.approach, for positions. Pass speed * dt.
vec3:withY
v:withX(n) / v:withY(n) / v:withZ(n) — the same vector with one component replaced. node.vel:withY(0) keeps your fall speed out of a horizontal speed clamp.
yawOf
yawOf(dir) — the yaw that faces along a direction. This is atan2(-x, -z) (engine forward is −Z), once and with the right signs. Zero direction → 0.
-- which way is that? (atan2(-x, -z), once and correctly)
local heading = math.deg(yawOf(node.vel))
scene lookups & raycast
capsulecast
capsulecast(origin, dir, radius, halfHeight, max [, opts]) — the player-shaped sweep: "can I actually move there", asked with the shape that will be moving. Upright along the capsule's own axis, matching how the solver keeps a capsule body aligned, so the cast and the move agree. Same hit fields as raycast, hit.material included.
find
find("Player") — the first node in the scene with that name (a node handle), or nil.
-- cache in start; find() every frame is wasteful
function start(node) player = find("Player") end
findAll
findAll("Coin") — an array of every node with that name.
findScript
findScript("GameManager") — a script handle for the first node anywhere running that script (the manager pattern), or nil. Call its methods / read its state. RESERVED KEYS: a handle answers node (its own node), kind (which script it is) and valid (still loaded?) ITSELF, so a script exporting one of those three can reach it and nobody else can — the editor lints the export and the Console says so at load. name is NOT reserved: a script's own name wins, and kind is the same string.
findScriptInScene
Alias of findScript(kind).
findScripts
findScripts(kind) — EVERY node carrying that script, as script handles in scene order. Pair with net.isMine to pick the local player out of many avatars: for _, s in ipairs(findScripts("third_person")) do if net.isMine(s.node) then ... end end
findTagged
findTagged("enemy") — EVERY node carrying that tag (Inspector tag chips / node:addTag), as node handles in scene order. Empty table when none; findTagged("enemy")[1] grabs the first.
for _, e in ipairs(findTagged("enemy")) do
if distance(node, e) < 10 then e:destroy() end
end
hit.nx
Contact normal X (unit, out of the hit surface).
hit.ny
Contact normal Y.
hit.nz
Contact normal Z.
hit.x
Contact point X (world).
hit.y
Contact point Y (world).
hit.z
Contact point Z (world).
overlapSphere
overlapSphere(center, radius [, opts]) — everything inside a sphere, DEEPEST overlap first, as hit tables ({x,y,z, nx,ny,nz, distance, node, material}) — the same fields raycast returns. Reports static geometry AND body hulls. opts takes { exclude = node, layers = {"Enemies"} }. The blast-radius / "what is in this area" query.
raycast
raycast(origin, dir, max [, ignore]) — or raycast(ox,oy,oz, dx,dy,dz, max [, ignore]). Cast a ray against the terrain + mesh colliders AND every physics body (players, crates). Returns a hit {x,y,z, nx,ny,nz, distance, node, material} or nil. node is the node it hit, whether that is a body or a piece of level. material is WHICH MATERIAL SLOT of it — the name a level author typed on that face — so a footstep can tell the boards from the grass inside one big map mesh; nil for anything with a single surface (terrain, a Collidable cube, an imported model, a body), and it costs nothing until you read it. Your own node's body is excluded; pass a node as ignore to skip its body too. The last arg can instead be an options table: raycast(..., { ignore = target, layers = {"Ground"} }) — layers (name or array, Project Settings → Layers) filters what the ray can hit; a misspelled layer is an error. Use for ground checks, line-of-sight, shooting.
local hit = raycast(node.pos, vec3(0, -1, 0), params.ground_ray)
if hit then log("ground at " .. hit.y) end
spherecast
spherecast(origin, dir, radius, max [, opts]) — the first thing a moving BALL of that radius would hit, or nil. A raycast that can't slip through a gap narrower than the thing you are actually moving. The hit carries the same fields raycast's does, hit.material included — the usual ground check for a footstep.
references — wire nodes in the Inspector
componentref
defaults = { body = componentref("RigidBody") } — the param binds to that COMPONENT on the wired node: params.body is a component handle directly (params.body.friction = 0.05). Components: RigidBody, PointLight, Camera, ParticleSystem, UiElement, UiSlider, UiLayer. nil while unwired/invalid.
noderef
defaults = { target = noderef() } — a NODE REFERENCE param: the Inspector shows a node picker (or drag a node from the Hierarchy onto it) and the script reads params.target as a node handle (nil while unwired). The preferred way to point a script at a specific node — no find() calls.
scriptref
defaults = { hp = scriptref("health") } — the param binds to that SCRIPT on the wired node: params.hp is a script handle directly (call its functions, read its state). The Inspector only lists nodes carrying the script. nil while unwired/invalid.
input — keyboard & mouse
input
Player input (play mode). input.key/pressed/axis/mouse/button — make interactive games.
input.action
input.action("Jump") — true while a NAMED action is held, from any of its bindings (key, mouse button, pad button, trigger). Define actions in Project Settings → Input; the list there is scanned from your scripts, so a name you type here shows up ready to bind. Prefer actions over input.key: they work on a gamepad, the player can rebind them, and they're what multiplayer replicates.
-- actions, not raw keys: rebindable, gamepad-ready, replay-safe
if input.action("jump") and node.grounded then
node.vel = node.vel + node.up * params.jump
end
input.actions
input.actions() — every action name in the map, for drawing an in-game controls screen.
input.aimPitch
The active camera's world pitch (radians), captured with the input snapshot.
input.aimYaw
The ACTIVE camera's world yaw (radians), captured with the input snapshot — use it for camera-relative movement (in multiplayer it rides the input command, so server + prediction replay see exactly your view angle). nil without an active camera.
input.axis
input.axis("a", "d") — returns -1/0/1 from a negative/positive key pair (e.g. strafing).
input.axis1
input.axis1("Zoom") — a named 1D axis in -1..1 (triggers, wheel, or a key pair).
input.axis2
local x, y = input.axis2("Move") — a named 2D axis clamped to the unit disk. Reads identically on WASD and on a stick; deadzone and SOCD are handled for you.
local mx, my = input.axis2("move")
node.pos = node.pos + (node.right * mx + node.forward * my) * params.walk * dt
input.bindingsOf
input.bindingsOf("Jump") — an action's bindings as printable chips ("⌨ Space", "🎮 South").
input.buffered
input.buffered("Punch", 4) — was it pressed within the last 4 TICKS and not yet consumed? The input buffer: a player who hits Punch a couple of frames before recovery ends still gets the punch. Pair with input.consume so it fires once. fixedUpdate only.
input.button
input.button(0) — true while a mouse button is held (0 left, 1 right, 2 middle).
input.cancelRebind
input.cancelRebind() — abandon a rebind in progress, leaving the old binding alone.
input.clicked
input.clicked(0) — true only on the frame a mouse button goes down.
input.commitRebind
input.commitRebind() — accept the captured binding. Returns false if nothing was captured yet.
input.consume
input.consume("Punch", 4) — spend a buffered press. Without it a 4-tick buffer fires your attack on all four ticks.
input.dir
input.dir() — the current numpad direction from "Move", from the character's point of view: 7 8 9 / 4 5 6 / 1 2 3, where 5 is neutral and 6 is forward.
input.dirHeldTicks
input.dirHeldTicks(4) — consecutive ticks a numpad direction has been held. Build your own charge or leniency rules on it.
input.facing
input.facing() — which way this player's character is facing, as -1 or 1. The fighter layer mirrors directional input by it, so "forward" means toward the opponent on both sides of the screen.
input.heldSecs
input.heldSecs("Charge") — seconds the action has been continuously held (0 when up). Hold-to-charge without your own timer.
input.justPressed
input.justPressed("Punch") — true only on the frame (or tick, inside fixedUpdate) the action goes down.
input.justReleased
input.justReleased("Block") — true only on the frame/tick the action goes up.
input.key
input.key("w") — true while the key is held. Names: a-z, 0-9, space, enter, shift, ctrl, alt, left/right/up/down, escape, tab.
input.lockMouse
input.lockMouse() — pin the cursor to the window center and hide it (FPS / free-look mouselook without holding a button). Read motion with input.mouse_delta(). Released on Stop.
input.motion
input.motion("qcf") — has a fighting-game motion just been completed? Seeded set: qcf, qcb, dp, rdp, hcf, hcb, dd, ff, bb, chargeF, chargeU (edit them in input.ron). Combine with input.buffered for a special: if input.motion("qcf") and input.buffered("Punch", 4) then. fixedUpdate only.
input.mouse
local x, y = input.mouse() — cursor position in pixels.
input.mouse_delta
local dx, dy = input.mouse_delta() — mouse movement since last frame.
input.padAxis
input.padAxis(1, "leftx") — read a pad axis raw, -1..1, past the action map. Same diagnostic purpose as input.padButton; bind through actions for real gameplay.
input.padButton
input.padButton(1, "a") — read a pad button RAW, straight past the action map. Deliberately unmediated: this is what distinguishes "your pad works, your bindings are wrong" from "your pad is not here".
input.padCount
input.padCount() — how many gamepads are connected. The quick check behind a "press a button to join" prompt.
input.pads
input.pads() — every gamepad the engine has enumerated: { index, name, connected }. Show it in your options screen; "the pad isn't listed" and "the pad is listed but nothing is bound" are different problems and only this can tell them apart.
input.pendingRebind
input.pendingRebind() — the captured chip text once something has been pressed, an EMPTY string while still waiting, or nil when no rebind is running. Enough for a menu to show "press any button…" and then the result.
input.player
input.player(2) — the same input API bound to another LOCAL player (1-based). Two characters can run the same script: pass the slot as a param and use local me = input.player(params.player). Set the count in Project Settings → Input. Sharing ONE keyboard: scope a binding to a player (right-click its chip) so a single action name can be J for P1 and 1 for P2 — pads sort themselves out already.
input.popContext
input.popContext("menu") — remove an input layer. Returns whether one was removed.
input.pressed
input.pressed("space") — true only on the frame the key goes down (an edge).
input.pushContext
input.pushContext("menu", { priority = 100, consume = true, enabled = { "Pause" } }) — a consuming layer swallows every action it doesn't list, so a menu or dialogue eats movement without the player controller knowing. Pop it with input.popContext("menu").
input.released
input.released("space") — true only on the frame the key goes up (an edge).
input.scroll
input.scroll() — mouse wheel delta this frame.
input.setFacing
input.setFacing(-1) — mirror this player's directions after a cross-up, so motion("qcf") keeps meaning "toward the opponent". The engine has no opinion about who faces where; the game sets it.
input.setMouseLocked
input.setMouseLocked(true/false) — lock or unlock the mouse from a boolean (e.g. a menu toggle).
input.startRebind
input.startRebind("Jump", "pad") — arm press-to-bind from a settings menu. Poll input.pendingRebind() for the captured chip, then input.commitRebind(). Filters: "keyboard", "pad", "axis", or nil for any button. Escape always cancels.
input.typed
input.typed() — the CHARACTERS entered this frame, as a string, resolved by the OS keyboard layout (a paste folded in). Not the same question as input.pressed: that one is physical, so "q" is the key where Q sits on QWERTY and types a on AZERTY. Never contains control characters — Enter and Backspace stay actions. Empty while a UI text field has focus, because the field ate them.
input.unlockMouse
input.unlockMouse() — release the cursor back to the desktop and show it again.
drawing — draw.*
draw
The GAME's telegraph layer — 3D lines/shapes and screen-space rects, circles and text that SHIP with your game. gizmo.* is the debug-only twin that never appears for a player.
draw.box
draw.box(cx,cy,cz, hx,hy,hz, yaw, r,g,b [,a]) — a yaw-rotated wireframe box from half-extents. Trigger volumes, build footprints, an attach point.
draw.circle
draw.circle(x, y, radius, r,g,b [, a]) — a filled circle in screen pixels, x/y its CENTRE. draw.circleOutline(..., [px]) is the hollow twin. Same immediate-mode rules as draw.rect: over the scene, over the HUD, one frame each.
-- x, y is the CENTRE
draw.circle(mx, my, 6, 0.3, 1.0, 0.5, 0.9)
draw.circleOutline(mx, my, 18, 0.3, 1.0, 0.5, 0.5, 2)
draw.circleOutline
draw.circleOutline(x, y, radius, r,g,b [, a] [, px]) — a hollow circle, px thick (default 2).
draw.cone
draw.cone(bx,by,bz, dx,dy,dz, radius, height, r,g,b [,a]) — a SOLID cone: base disc at b, apex height along the unit direction d. Gizmo arrowheads, thruster plumes, direction markers.
draw.disc
draw.disc(cx,cy,cz, nx,ny,nz, r0, r1, r,g,b [,a]) — a filled annulus around normal n (r0 = inner, r1 = outer; r0 = 0 gives a full disc). Rotation gizmo bands, ground markers.
draw.line
draw.line(x1,y1,z1, x2,y2,z2, r,g,b [, a]) — queue one world-space 3D line for THIS frame (immediate mode: re-draw every lateUpdate — the camera pass — while wanted). Drawn OVER the scene, never occluded — the KSP-style map draws its orbit conics with these.
draw.quad
draw.quad(texture, x0,y0,z0, x1,y1,z1, x2,y2,z2, x3,y3,z3, r,g,b [,a] [,u0,v0,u1,v1]) — one textured quad IN the world: depth-tested against the scene (a trail behind a pillar stays behind it), blended with the image's alpha, visible from both sides. The corners run around the quad; the colour tints the image. The UV rectangle defaults to the whole image — corner 0 sits at (u0,v0), corner 1 at (u1,v0), corner 2 at (u1,v1), corner 3 at (u0,v1). A ribbon is one quad per segment with u walking along it: paint the streak with u running tail→head and v across the width, then give segment i the slice u = i/n .. (i+1)/n. A sword trail, a tyre mark, a decal, a ground ring. Immediate mode: draw it every lateUpdate you want it.
-- a ribbon between two marker nodes on a moving object (a blade's mid and tip):
-- sample them each frame, keep half a second, one textured quad per segment
local samples = {}
function lateUpdate(node, dt)
local tip, mid = node:find("BladeTip"), node:find("BladeMid")
samples[#samples + 1] = { tip = tip.worldPos, mid = mid.worldPos, t = time }
while time - samples[1].t > 0.5 do table.remove(samples, 1) end
local n = #samples
for i = 2, n do
local a, b = samples[i - 1], samples[i]
local alpha = 1 - (time - b.t) / 0.5
-- u walks tail→head along the ribbon, v runs mid→tip across it
local u0, u1 = (i - 2) / (n - 1), (i - 1) / (n - 1)
draw.quad("textures/streak.png",
a.tip.x, a.tip.y, a.tip.z, b.tip.x, b.tip.y, b.tip.z,
b.mid.x, b.mid.y, b.mid.z, a.mid.x, a.mid.y, a.mid.z,
0.6, 0.9, 1.0, alpha, u0, 0, u1, 1)
end
end
draw.rect
draw.rect(x, y, w, h, r,g,b [,a] [,radius]) — a filled rectangle in SCREEN PIXELS, in input.mouse()'s space. An RTS marquee is just the two corners you dragged between — the 3D version has to be projected onto a ground plane, which fights the camera angle and misses whatever the plane doesn't cross.
draw.rectOutline
draw.rectOutline(x, y, w, h, r,g,b [,a] [,thickness]) — the hollow twin of draw.rect. The last number is the border thickness rather than a corner radius.
draw.ring
draw.ring(cx,cy,cz, nx,ny,nz, radius, r,g,b [,a]) — a circle around normal n at c. Range rings, selection circles, an area-of-effect telegraph.
draw.sphere
draw.sphere(cx,cy,cz, radius, r,g,b [,a]) — three rings, i.e. a wireframe ball. Cheap enough to draw per-frame for every marker on screen.
draw.text
draw.text(x, y, s, size, r,g,b [, a] [, align] [, font]) — a string on the SCREEN, in the pixels input.mouse() reports, without building a UI tree: a damage number, a frame-time readout, the count under a selection box. The engine measures and lays out the glyphs with the same font stack ui.make uses — and measures with the SAME font it draws, so a centred run lands where you asked. align is "left" (default) | "center" | "right", and x is that edge. font is a project-relative .ttf/.otf; leave it out and you get the project's UI font (Project Settings ▸ UI font), which is where to set it once rather than at forty call sites. Immediate mode: re-draw it every frame you want it.
-- a HUD with no UI tree; align says which edge x is
draw.text(24, 24, "HP " .. hp, 22, 1, 0.4, 0.4)
draw.text(w - 24, 24, string.format("%.0f fps", 1 / dt), 18, 1, 1, 1, 0.7, "right")
draw.tri
draw.tri(x1,y1,z1, x2,y2,z2, x3,y3,z3, r,g,b [,a]) — one filled triangle. The raw primitive under the solid shapes, for when you want your own.
the web — http., json.
http.cancelAll
http.cancelAll() — forget every pending callback. Stop and scene.load do this for you: a callback closes over nodes from the scene that asked, and delivering it into a fresh session is how one run inherits the previous one's network.
http.delete
http.delete(url [, opts], function(res) end) — as http.get, with DELETE.
http.get
http.get(url [, opts], function(res) end) — fetch a URL. NON-BLOCKING: the callback runs on a later tick on the MAIN thread, so it is safe to touch nodes from it and a slow server can never stall a frame. opts = { headers = {...}, timeout = 10, json = true }. res = { ok, status, body, json, error, location } — ok is a 2xx with no error; a 404 still hands you body, because that is where an API explains itself; a 3xx is NOT followed — location says where it pointed. Public addresses only: loopback, private-network and link-local addresses are refused (the editor's Play allows loopback and private ones, and says so once). Play only.
-- non-blocking: the callback runs on a later tick, on the main thread
http.get(params.api .. "/me/cards", {
headers = { Authorization = "Bearer " .. token },
}, function(res)
if not res.ok then return log("failed: " .. tostring(res.error)) end
for _, card in ipairs(res.json.cards or {}) do addCard(card) end
end)
http.inFlight
http.inFlight() — how many requests are still waiting on a reply. Up to 8 may be in flight and 20 may start per second; past that, calls fail fast with res.error and the cap announces itself once in the Console. A cap you are hitting is nearly always a request inside update().
http.post
http.post(url, body [, opts], function(res) end) — same rules as http.get, plus a body: a STRING is sent as-is, a TABLE is encoded as JSON for you. http.put and http.delete round out the set.
-- a TABLE body is sent as JSON; no json.encode dance needed
http.post(params.api .. "/me/loadout", { deck = deckId }, function(res)
if not res.ok then log("the server said no: " .. res.body) end
end)
http.put
http.put(url, body [, opts], function(res) end) — as http.post, with PUT.
json.array
json.array(t) -> t — mark a table as a JSON LIST, and return it. The encoder guesses from the shape (keys 1..n and nothing else is an array), which is right for every list with something in it and cannot be right for the empty one: {} is both an empty list and an empty object, and it stays an object. So json.encode{ ids = json.array{} } sends "ids":[] where a plain {} would send "ids":{} and the server would read the wrong type. json.array() with no argument builds a new empty list, and json.array(t) returns the SAME table it was given, so local ids = json.array{} then ids[#ids+1] = x reads normally. json.decode marks every array it builds, so read -> edit -> send back keeps its lists as lists; note that body.ids = {} throws the mark away with the table, and body.ids = json.array{} is the replacement that keeps it. A marked table that also carries a name, or that has a hole in it, is REFUSED by json.encode with a message saying which.
json.decode
json.decode(s) -> value, err — parse JSON. Bad input returns nil AND a message rather than raising: a reply from someone else's server is data, not a bug in your script. JSON null becomes nil, so a null field reads exactly like a missing one. Refuses: the string is not valid JSON. It returns the message rather than raising, on purpose — a reply from someone else's server is data, not a bug in your script.
-- bad input is a VALUE, not an error
local save, why = json.decode(text)
if not save then return log("corrupt save: " .. why) end
json.encode
json.encode(value) — a Lua value as a JSON string. A table with a [1] is an ARRAY, anything else is an object (that is the only rule Lua's single table type can support), and json.array(t) says list for the empty case the shape cannot answer. http.post takes a table body directly, so you rarely need this by hand.
json.isArray
json.isArray(v) -> bool — would json.encode write this as a JSON array? True for a table marked by json.array, and for any table whose keys are exactly 1..n with n at least 1. FALSE for an empty unmarked table, which is the whole point: this is how json.decode('[]') and json.decode('{}') are told apart, and before it they were the same empty table.
openUrl
openUrl(url) — open an http:// or https:// address in the player's own browser. The address is parsed first: one with a username in it, whitespace, or a character a shell would read is refused at the call, with the reason. The device-code sign-in flow needs it: the player approves the pairing on your real site, so the game never sees a password and needs no secret baked into it. Play only; if the platform refuses, the URL is logged instead so the player can still get there.
-- the player approves the pairing on your real site
openUrl(res.json.verify_url)
the player's account — account.*
account
The signed-in player: account.signIn(), account.player(), and http verbs that carry the session. A script asks for a PLAYER, never for a token, and the server decides what that player owns.
account.cancel
account.cancel() — abandon a sign-in in progress (the player pressed Escape). Harmless at any other time.
account.code
account.code() — while state() is "waiting": { code = "WXYZ-9999", url = "...", expiresIn = 900 }. Show the code and send them to the url (openUrl does it) — that pairing is what the player approves. nil at any other time, and ALWAYS nil in a browser build, which redirects rather than pairing a code — so a sign-in screen that only draws the code shows an empty box there. Draw a button that calls signIn() as well.
account.delete
account.delete("/games/mygame/saves/slot1", function(res) end) — remove something from Floptle Cloud.
account.error
account.error() — why the last sign-in failed, as a sentence you can put on screen. nil unless state() is "failed".
account.get
account.get("/wallet", function(res) end) — a Floptle Cloud call with the player's bearer token attached for you. Takes a PATH, not a URL: there is exactly one host it can reach, which is what makes attaching a token to it safe. Bare paths get the /api/floptle/v1 prefix. A script acts as the PLAYER: /wallet, /missions, /games/... and /me/... are callable; the developer surface under /cloud/... and the identity endpoints are refused at the call. res is the same table http.* gives you.
account.inFlight
account.inFlight() — how many account calls are still waiting on a reply (cap 6). A spinner, or a guard against firing the same request every frame.
account.player
account.player() — { id, name, email, tier } once signed in, else nil. tier is "unknown" when fopull.com could not be reached to ask what the plan is — the engine fails soft to the free tier's limits but will not report a guess as the answer, so gate a paid feature on a tier you were TOLD rather than on "not free". There is deliberately no way to read the access token: a shipped game's Lua is readable, so anything a script can hold a player can read out of the file.
account.post
account.post("/games/mygame/events", { event = "boss_killed", event_id = id }, function(res) end) — report what HAPPENED and let the server decide what it is worth. A table body is sent as JSON. There is no endpoint that credits currency directly, by design: anything a client can announce, a modified client can announce.
account.put
account.put("/games/mygame/saves/slot1", { data = t, expected_version = v }, function(res) end) — a cloud save. expected_version is optimistic concurrency: send the version you last read and a stale write gets 409 instead of silently clobbering the player's other machine.
account.signIn
account.signIn() — begin signing the player in to their Foverse account (fopull.com). Returns IMMEDIATELY; watch account.state() and draw account.code(). The engine drives the OAuth device flow in Rust — the player approves in their browser, so the game never sees a password and never holds a token. Play only. IN A BROWSER BUILD this is a redirect instead: the page leaves for fopull.com and the game RESTARTS when it comes back already signed in, so call it from a menu and never mid-play, and expect account.code() to stay nil (there is no code to show). The game's address has to be registered as a redirect URI first, or the sign-in is refused.
account.signOut
account.signOut() — forget the session NOW, then clear the keyring and revoke the refresh token in the background. In that order on purpose: a player who presses Sign Out is signed out whether or not the network agrees.
account.state
account.state() — "signedOut" | "starting" | "waiting" | "signedIn" | "failed". Polled rather than called back, because signing in takes as long as a person takes to pick up their phone and a sign-in screen is redrawing anyway.
game UI — text, buttons & hooks
cancelled
function cancelled(node) — UI hook: the UiCancel action (Escape / B) while this element has focus. Back out of a screen from the element the player is on.
changed
function changed(node) — UI hook: a text field's value changed (typing, paste, backspace). Once per frame however many keystrokes landed. Read node.text.
clicked
function clicked(node) — UI button hook: fires when this node's element (with 'button' on) is pressed AND released on it. Style states in Lua; no imposed look.
color
color(r, g, b [, a]) — a colour, 0..1 per channel, alpha 1 by default. Also color(gray [, a]) and color(other [, a]) to copy with a new alpha. It's a plain table {r,g,b,a} (also [1]..[4]) so it prints, saves and compares. Assign it whole: el.fill = color(1, 0.85, 0.35), el.textColor, el.borderColor, el.tint, el.groupTint, el.caretColor.
color.hex
color.hex("#ff8800") / color.hex("ff8800aa") — 6 or 8 hex digits. A 3-digit shorthand is refused rather than guessed at.
color.lerp
color.lerp(a, b, t) — blend two colours per channel, t clamped to 0..1.
dragCancel
function dragCancel(node) — UI hook: a drag was released over nothing. Put the item back; a half-finished gesture must not leave it stuck to the cursor.
dragEnter
function dragEnter(node) — UI hook: a drag moved over this drop target. Pair with dragLeave; highlight the slot here.
dragLeave
function dragLeave(node) — UI hook: the drag moved off this drop target.
dragMove
function dragMove(node) — UI hook: fires every frame of a drag on the SOURCE. Use input.mouse() / node:uiRect() to position whatever you're showing.
dragOver
function dragOver(node) — UI hook: fires every frame a drag rests over this drop target.
dragStart
function dragStart(node) — UI hook: a draggable element has been picked up (the pointer travelled far enough that it isn't a click). The engine does NOT move the element — draw the drag however your game wants.
dropped
function dropped(node) — UI hook: fires on BOTH ends of a completed drag — the target (which now has it) and the source (which gave it away). ui.dragging() and ui.dropTarget() name the pair.
el.border
Shape border thickness (design units).
el.cell
Spritesheet cell index the image shows (set per frame for sprite animation).
el.fillA
Shape fill alpha 0..1.
el.fillB
Shape fill blue 0..1.
el.fillG
Shape fill green 0..1.
el.fillR
Shape fill red 0..1.
el.height
Height (same rules as width).
el.opacity
Multiplies every color the element draws, 0..1.
el.posX
Free position X / Pin offset X (design units).
el.posY
Free position Y / Pin offset Y (design units).
el.radius
Shape corner radius (design units).
el.scrollY
Scroll-view position, design units (0 = top; the wheel drives it too, clamped to the content). Present only on elements with the scroll-view option.
el.textA
Text color alpha 0..1.
el.textB
Text color blue 0..1.
el.textG
Text color green 0..1.
el.textR
Text color red 0..1.
el.textSize
Text glyph size (design units; ignored while fit is on).
el.tintA
Image tint alpha 0..1.
el.tintB
Image tint blue 0..1.
el.tintG
Image tint green 0..1.
el.tintR
Image tint red 0..1.
el.visible
Shown (1/0; assign true/false).
el.width
Width in the axis's sizing mode (px value, % fraction, or grow weight). Absent (nil) on a fit axis; writing one makes it fixed px.
focusEnter
function focusEnter(node) — UI hook: keyboard/gamepad focus arrived here. What focus LOOKS like is your style's focus block; this is for the rest (a sound, a preview, a description panel).
focusExit
function focusExit(node) — UI hook: focus left this element.
hoverEnd
function hoverEnd(node) — UI hook: the pointer left this node's clickable element.
hoverStart
function hoverStart(node) — UI hook: the pointer entered this node's clickable element. Pair with hoverEnd.
layer.designHeight
Design units that span the window height.
layer.enabled
Master switch (1/0; assign true/false) — an off layer draws nothing.
layer.textSnap
Round every rasterized text size to a whole multiple of this many SCREEN PIXELS; 0 = off. For a pixel font, whose art is a grid: a cell only looks like a pixel when it lands on a whole one, and text size x layer scale almost never does — so every stem is softened by a different fraction and the text reads as badly spaced even though nothing is mispositioned. Set it to the number of cells in an em.
layer.worldSpace
1 = a panel inside the 3D world at this node's transform; 0 = a screen overlay.
layer.z
Draw order: lowest z first.
node.index
Which row of a UI repeater this node is, 0-based — nil on anything a repeater didn't spawn, so if node.index then is a fine "am I a row". Read the count with getcomponent("UiElement").count on the container.
node.text
A UI element's label text — read/write; numbers coerce (hpLabel.text = 42). nil on nodes without UI text; writing to a UI element without a text spec creates one.
pressed
function pressed(node) — UI hook: LMB went down on this node's clickable element.
released
function released(node) — UI hook: LMB came back up (on or off the element).
slider.max
Range end.
slider.min
Range start.
slider.value
Current value (clamped to min..max at draw time).
submitted
function submitted(node) — UI hook: Enter (UiSubmit) in a focused TEXT FIELD. Read the value with node.text. A field fires this instead of clicked, so a field inside a button doesn't run the button.
ui.bind
ui.bind(node, "property", function() ... end) — say the relationship once instead of writing an update() that keeps it true. The engine calls the function once a frame, after every update, and writes what it returns: a string or number to "text", a color(...) to a colour field, a number/boolean to any component field (the component is picked by which one actually has that field, so "value" finds UiSlider). Re-binding the same property replaces. A binding whose node is gone is dropped silently; one that throws is dropped after reporting once.
ui.changed
ui.changed(element) — a text field's value changed this frame. Read the value with element.text.
ui.clicked
ui.clicked(element) — did it fire clicked THIS frame? The polling half of ui.on, for a manager that already has an update(). Reads the same event list the hooks fire from (published before scripts run), so a poll and a hook can never disagree.
ui.dragging
ui.dragging() — the element being dragged, as a node, or nil. Live for the whole drag AND for the frame the dropped hooks run on. There is no separate payload channel because a node already carries params, a name and tags — ask it what it is.
ui.dropTarget
ui.dropTarget() — the drop target the drag is currently over, as a node, or nil.
ui.event
ui.event(element, "dropped") — did that element fire that hook this frame? Any hook by name; ui.clicked/pressed/released/changed/submitted are the shorthands.
ui.events
ui.events() — everything that happened on the UI this frame, as { node = element, event = "clicked" } rows. ui.events("clicked") filters. Lets one manager handle a whole screen without naming a single element: for _, ev in ipairs(ui.events("clicked")) do ... end.
-- the whole screen, without naming a single element
function update(node, dt)
for _, ev in ipairs(ui.events("clicked")) do
log("clicked " .. ev.node.name)
end
end
ui.focus
ui.focus(node) — move the keyboard/gamepad focus. ui.focus(nil) drops it (a screen that wants nothing focused until the player touches something). Focusing a text field starts editing it.
ui.focused
ui.focused() — the focused element as a node, or nil. ui.focused(el) answers yes/no for one element. Also readable per-node as node.focused.
ui.held
ui.held() — the element the pointer is holding down, as a node, or nil. ui.held(el) answers yes/no. Hold-to-charge, press-and-hold repeat, a dip while pressed.
ui.hovered
ui.hovered() — the element under the pointer, as a node, or nil. ui.hovered(el) answers yes/no for one element. A STATE, not an event: true for as long as it's true (hoverStart/hoverEnd are the edges).
-- a state, not an event: true for as long as it's true
local over = ui.hovered()
find("Caption").text = over and over.name or ""
ui.make
ui.make(container, tree) — build a UI subtree from data and RECONCILE it with the one already there: call it again and only the difference is spawned and destroyed, so surviving rows keep their entity, their hover, their scroll and their in-flight transitions. An element is { "kind", prop = value, ..., children }, where kind is box/row/col/text/image/button/field/slider/scroll. items = {...} plus a function child makes one child per item (the function gets (item, i); return nil to skip it). key = "id" is how a row is matched through a re-sort. onClicked = function(node) ... end (any UI hook, on + its name) carries behaviour inline — no prefab, no script file. Properties the table stops mentioning go back to default; what the PLAYER did (scroll, typing, a toggle, a dragged slider) is kept. Play only, and a mistyped property raises rather than being ignored. Elements you placed by hand under the same container are never touched.
ui.make(find("Crew Panel"), {
"col", gap = 8, pad = 12, style = "panel", items = crew,
function(m) return { "text", key = m.id, text = m.name } end,
})
ui.off
ui.off(element) stops every hook YOUR script is listening to on that element; ui.off(element, "clicked") stops one. Only your own — two managers on one button must not be able to unregister each other.
ui.on
ui.on(element, "clicked", function(el, hook) ... end) — listen to an element from a script that does NOT live on it, so ONE manager holds a whole menu instead of a three-line script file per button. Any UI hook: clicked, pressed, released, hoverStart, hoverEnd, changed, submitted, cancelled, focusEnter, focusExit, dragStart/Move/Enter/Over/Leave/Cancel, dropped. The handler gets the element that fired and the hook name, so one function can serve a row of buttons. Registering again for the same element and hook REPLACES (so calling it from update() is harmless, not a leak). A listener dies with its element or with the script that registered it; a hot reload re-registers. Listening for an interaction the element doesn't take warns in the Console — it would otherwise be silent.
-- one menu script instead of a script file per button
function start(node)
ui.on(find("Play"), "clicked", function() scene.load("level1") end)
for _, b in ipairs(find("Toolbar"):children()) do
ui.on(b, "clicked", function(el) selectTool(el.name) end)
end
end
ui.pressed
ui.pressed(element) — LMB went down on it this frame. Pair with ui.held(element) for hold-to-charge.
ui.released
ui.released(element) — LMB came back up this frame (on or off the element).
ui.submitted
ui.submitted(element) — Enter in this focused text field this frame.
ui.unbind
ui.unbind(node) drops every binding on that node; ui.unbind(node, "text") drops one.
networking — net.*, synced
net
Multiplayer: host and join, synced state, RPCs, ownership (net.isMine), and the rollback readouts. Open netcode — you can self-host the relay.
net.despawn
SERVER ONLY: net.despawn(node) — remove a replicated runtime object, and the whole subtree it spawned with, everywhere.
net.host
net.host{ maxPlayers = 16, port = 7777, relay = "addr", interest = 150, interestBudget = 16384 } — become the authoritative host. relay = a rendezvous relay address (you get a LOBBY CODE, nobody port-forwards), or "cloud" for Floptle Cloud's managed relay — which needs the project connected to a game at fopull.com/cloud, and refuses a keyless host by telling you where to get one. "cloud:us-east" pins a region instead of picking the nearest. port = direct UDP (QUIC) for LAN; neither = the in-editor loopback harness. Refused while this peer is in a session (joined, joining, or already hosting) — net.leave() first; the session you have is kept. interest = metres: each client hears about its own neighbourhood instead of the whole world (leave it off below a few dozen players — broadcasting is cheaper); interestBudget = bytes/sec of entity updates per client; inputDelay = rollback input delay in TICKS (clamped to 6) — omit it and the host derives one from the worst peer's measured RTT (2 on a LAN, 5 across a country).
net.identity
net.identity(peer) — who a connected peer is: { id, name, tier, verified }. id is the account's stable subject, the same across sessions and machines; an anonymous peer (a LAN game with nobody signed in) has no id, which is a normal state and not an error. READ verified BEFORE ACTING ON id: it is false for everyone today, because the engine carries what a client says about itself and has no way to check it with the provider yet — so a ban list or a statistic keyed on an unverified id is keyed on a string the client chose. See docs/multiplayer.md, "Running a public server".
tier is the plan the account is on, or "unknown" when that peer's client could not reach fopull.com to ask. A named tier is one the server confirmed; "unknown" is the engine refusing to guess, which it used to do by reporting "free" — so a peer that reads as free really is free.
net.inputDelay
net.inputDelay() — the session's FIXED input delay in ticks. Never changes mid-match, because how the game feels must not.
net.isClient
net.isClient() — true on a connected client.
net.isDedicated
net.isDedicated() — true on a server with NOBODY SITTING AT IT: floptle serve / floptle-server, as opposed to a player hosting the game they are in. Both answer net.isServer() the same way, and for the simulation that is right — it does not care who started it.
This is the one thing that differs, and a game cannot work it out for itself. A dedicated server must not take a seat in its own lobby, deal itself a character, count toward "enough players to start", or be waited on to press Ready — and a lobby that treats it as a player can never begin a match, because the empty chair never readies.
net.isMine
net.isMine(node) — is this node under MY control on this machine? Offline/non-networked → true; server → true unless a remote peer owns it; client → only your own predicted node(s). Cameras/HUDs use it to pick the local player out of many avatars (pair with findScripts).
net.isServer
net.isServer() — true on the authoritative host.
net.join
net.join(addr) — join a session: "cloud://UABCDE" = a Floptle Cloud lobby code (six characters; the first names the region, so it resolves with no call to fopull.com and works during an outage), "relay://relayaddr/CODE" = a lobby code through any relay (no port-forwarding), "quic://host:port" = a server directly, "local://" = the in-editor test harness. Optional second argument is an options table: net.join(addr, {timeout = 30}) sets how long to wait on a server that is WAKING UP (default 90 s) before giving up with "refused". It bounds only a wake — an ordinary join is answered in one relay round trip.
net.joinState
net.joinState() -> state, reason — how a join is going: "offline" | "connecting" | "joined" | "refused" | "starting", plus "reconnecting" on a RELAY HOST whose relay is unreachable — during which net.lobbyCode() is nil, because the code the relay gave you does not exist while the relay does not have it. "starting" means the lobby is REAL and its dedicated server is waking up — the engine keeps retrying by itself and gives up after net.join's timeout option (default 90 s), then reports "refused" with "took too long to start" — it is not "refused", which means the attempt will never succeed, and it can take tens of seconds where "connecting" is one relay round trip. On "refused", "reconnecting" and "starting" the second return says why or how long, in the relay's own words ("no lobby QK7RM", "about 20 seconds") — print it. WAIT ON THIS, not on net.role(): joining does not block, so role reads "client" from the frame you called net.join, whether or not that code matched any lobby.
net.kick
SERVER ONLY: net.kick(peer, reason) — remove a player, with words that reach them. The reason goes out before the link closes, so their UI can say what happened instead of showing the generic "connection lost" every unexplained drop produces; playerLeft fires with it attached, and on their machine net.on("kicked", fn) does. Kicking is not banning: without a verified id it lasts until they reconnect — pair it with net.host{ deny = { ids } }.
net.leave
net.leave() — end the session.
net.lobbyCode
net.lobbyCode() — the code friends type in to join, on a host that used net.host{ relay = "…" }: six characters on Floptle Cloud (they join with net.join("cloud://" .. code)), five on your own floptle-relay. Put it on your own lobby screen. nil until the relay answers (POLL it, don't read it once), and nil for good on a client or a direct/LAN host — there is no code there, joiners use the address.
net.mispredictRate
net.mispredictRate() — 0..1, the fraction of simulated ticks that had to guess a peer's input. Rises with latency; what the input delay is chosen against.
net.notice
net.notice() — what the relay last told this HOST about the session, or nil. Today there is one: your account is at its player ceiling, so new joins are being turned away — and nobody already playing was disconnected. Said once per episode, not once per refused join.
Worth putting on your own lobby screen. The people it concerns are the friends who cannot get in, and they are looking at your game rather than at your console; "we are full right now, someone will be along" is a far better experience than a join that silently fails. nil on a client and on a direct/LAN host, where there is no relay to say anything.
net.on
net.on(event, fn) — session events: playerJoined/playerLeft (peer id), connected, disconnected (reason).
net.peers
net.peers() — connected client peer ids (server).
net.ping
net.ping(peer?) — round-trip time in ms.
net.random
net.random(a?, b?) — deterministic RNG for a rollback match, drawn from (match seed, tick, draw index): every peer rolls the same number AND a re-simulated tick rolls it again. Use this instead of rng() in anything a rollback node reads — an unseeded roll comes from the clock, and two peers drawing differently is a match that quietly forks in two. No args → [0,1); one → integer 1..a; two → a..b.
net.replaying
net.replaying() — true while the engine is RE-SIMULATING ticks it already ran after a correction. For cosmetics the engine can't gate for you (a screen shake, a UI poke). NEVER branch simulation on it: a replayed tick that computes something different from the live one is the definition of a desync.
net.rewind
SERVER ONLY, inside onRpc for an rpc sent {withInput=true}: run the closure against the world as that peer PERCEIVED it — raycasts and other scripts' synced vars read the rewound tick (clamped ~250 ms). A parry that was up on the attacker's screen counts.
net.role
net.role() — "offline" | "server" | "client".
net.rollbackAverage
net.rollbackAverage() — mean ticks re-simulated per correction. The texture of the connection, where rollbackMax is only its worst moment. A healthy match sits low.
net.rollbackDepth
net.rollbackDepth() — ticks re-simulated by the most recent correction.
net.rollbackMax
net.rollbackMax() — the deepest rollback this session has had to perform: its worst moment.
net.rpc
net.rpc(name, args, {to=peer, withInput=true}) — remote call: server→clients or client→server. withInput stamps a client intent with the tick it was seeing (for net.rewind). Handle with function onRpc.name(args, sender). Args: scalars + tables (≤4 deep, ≤1KB).
net.setInputDelay
net.setInputDelay(ticks) — the rollback input delay for the NEXT match, in ticks, clamped to 6. Too low and the opponent's input lands after the tick that needed it on every tick, so the driver guesses and re-simulates: correct, and five times the work. Fixed for a session on purpose — adaptive delay hides a bad connection by changing how the game FEELS while you are playing it. Call it between matches; the roster re-announce restarts the driver.
net.setOwner
SERVER ONLY: net.setOwner(node, peer) — hand a replicated node to a player after it already exists; net.setOwner(node, nil) releases it back to the server. Ownership used to be settable only at spawn, which is why a player who dropped could never be given their own slot back. On a dedicated server, authored Predicted slots are handed out from #1 as players join and freed when they leave — a slot you assign yourself is never reassigned behind your back.
net.setRelevant
SERVER ONLY: net.setRelevant(node, peer, true|false) — decide per client whether that client may be told about that node at all, on top of the interest radius; pass nil to hand the decision back to the radius and the line-of-sight test. This is the hidden-role hook. It has to be server-side: a client that has already been sent a position knows the position, whatever it chooses to draw, so hiding or attenuating it client-side is a setting a modified client turns back off. Holds whether or not interest management is on: with it off, every client is still sent everything EXCEPT what it has been pinned away from. The 🌐 panel shows how many nodes each client is being withheld, and why.
net.spawn
SERVER ONLY: net.spawn(what, { x, y, z, owner }) — spawn a replicated runtime object on every client (available next tick). what is a prefab by name ("Knight" finds prefabs/Knight.prefab.ron), a prefab path, or a scene path ("scenes/thing.ron", whose first root node and its children are spawned). The table is optional: x, y, z are three numbers (not a vec3); owner = a peer id makes a Predicted rig that player's avatar. Offline, spawn(what) does the same thing without a session.
net.stalled
net.stalled() — true while the sim is waiting for a peer's input rather than guessing past the depth cap. The game runs slightly slow instead of teleporting the opponent. Drive your own "connection trouble" banner off this — a stall is otherwise indistinguishable from a bad frame rate.
net.traffic
net.traffic() -> {{kind=, count=, bytes=}, ...} — what this peer has SENT since the last call, broken down by message kind, biggest first. RESETS on read, so successive calls measure the interval between them. Use it to find out where your bandwidth goes: a rollback game should be almost entirely "Input"/"Inputs" and a few hundred bytes a second — a large "Snapshot" row in a rollback match means state is going out where inputs should.
onRpc
onRpc.(args, sender) — handles net.rpc("name", args). sender is the verified peer id (0 = server).
replicated
replicated = { hp = 100 } — declare synced script vars (top level). Read/write them as synced.hp; the server's writes replicate to every client.
restore
function restore(s) — the other half of snapshot(): put the table back. Called before the engine re-simulates a tick it already ran. Restore every key snapshot() returned, and nothing else.
snapshot
function snapshot() — REQUIRED on a rollback node's scripts. Return a flat table of every gameplay value this script owns (state, frame counters, health, stun). The engine calls it each tick and restores it when a correction arrives. ANYTHING you leave out is a value that survives a rewind unchanged — which is exactly what a desync is made of. Transforms and physics bodies are saved for you; do NOT put them in here.
synced
The synced-vars table (declared via replicated = {...}). Server writes replicate; client writes warn and get overwritten.
scenes — load, unload & persist
scene
Which world is loaded: scene.load / scene.unload, additive layers, and scene.onLoaded. Pair with node.persistent to carry a node across a swap.
scene.current
scene.current() — the running scene's name (its file stem, e.g. "first").
scene.list
scene.list() — every scene in the project as names scene.load accepts (sorted; subfolders kept).
scene.load
scene.load("arena") — switch to another scene at the next frame boundary: the world swaps, physics/animators/particles/audio rebuild, every start re-fires (like the scene booting fresh). Accepts a name, a scenes-relative path ("arenas/desert"), or "scenes/arena.ron". Multiplayer: only the SERVER may call it — every client follows automatically; a client's call is refused (send the server an RPC instead).
scene.onLoaded
scene.onLoaded(function(name, additive) ... end) — run something once a scene has finished loading. Fires AFTER the world is whole, because a loading screen's whole job is to go away once the thing it was covering exists.
scene.unload
scene.unload("Shop") — remove a scene that was loaded additively, and everything under it. The other half of scene.load{ additive = true }.
terrain — runtime sculpt & queries
terrain
Runtime sculpting and queries against the SDF terrain: dig, sculpt, paint, ask what is under a point, and persist edits per save slot.
terrain.busy
terrain.busy() — is the background terrain worker already occupied? True while any field is generating or streaming in. Whole-body fills and residency streaming share one background budget, so a game that BUILDS ITS WORLD AS THE PLAYER TRAVELS should ask before queueing the next one — otherwise the new world goes in behind the ground somebody is standing on. The pattern: build one thing, wait for this to go quiet, build the next.
terrain.deleteSaveDir
terrain.deleteSaveDir("saves/slot2/terrain") — delete a save slot's persisted terrain from disk (pair with save.deleteSlot in a "delete this save" UI). Narrow by design: relative path, no "..", must not be the ACTIVE saveDir, and only .cfield/.tfield/.meta files in that one directory are removed (emptied dirs are tidied). Returns the number of files removed.
terrain.dig
terrain.dig(x,y,z, radius [, strength]) — carve a hole: sugar for terrain.sculpt(..., "lower"). Pair with raycast(...) to dig where the player aims.
if input.clicked("left") then
local hit = raycast(node.pos, node.forward, 6)
if hit then terrain.dig(hit.x, hit.y, hit.z, 1.5) end
end
terrain.flush
terrain.flush() — checkpoint every EDITED resident terrain field to the save slot (terrain.saveDir must be set). Runs IN THE BACKGROUND (amortized encode + threaded write, deferred while a field is actively being dug) so autosaves never stutter; exit paths (Stop / scene.load) finish the writes synchronously so a checkpoint is never lost.
terrain.generatePlanet
terrain.generatePlanet(id [, opts]) — REPLACE terrain id's whole field with a generated planet (sphere ± noise relief, caves + chambers, molten core, craters, layered materials). Background-generated (seconds; Console shows progress). opts (all optional): radius, voxel, relief, bumpFreq, caveDepth, coreR, corePaint, craters, craterMin/Max, craterDust, surfaceA/B {slot,color}, patchBias/Thr, subsoil(+Depth), strata(+Depth), deep, pockets {slot,color,threshold,minDepth}, seam {slot,color,minDepth,center,width}, iceCaps {lat,slot,color}, seed.
terrain.height
terrain.height(x, z) — world Y of the highest terrain surface under (x,z), or nil when nothing is hit. Spawning, footstep audio by ground, drop-to-floor.
terrain.paint
terrain.paint(x,y,z, radius, r,g,b [, strength]) — recolor the terrain surface inside the brush ball (0..1 colors).
terrain.paintTexture
terrain.paintTexture(x,y,z, radius, slot) — paint a terrain-palette texture slot (1-based, the Terrain tab's palette; 0 clears to flat color).
terrain.query
terrain.query(x,y,z) — signed distance to the nearest terrain surface (negative = inside rock), or nil with no terrain. Cheap: read it every frame (burrow checks, depth meters).
terrain.saveDir
terrain.saveDir(path) / terrain.saveDir() — set (or read) the game's SAVE-SLOT directory for player-edited terrain, relative to the project root (e.g. "saves/slot1/terrain"). While set, streaming loads fields from here first (before the project file or the genspec) and writes edited fields back on stream-out — per-slot terrain persistence. "" clears; auto-cleared when Play stops.
terrain.sculpt
terrain.sculpt(x,y,z, radius [, strength [, mode]]) — sculpt the nearest terrain at a world point, landing the SAME tick (collision updates with the surface). mode: "raise" (default), "lower"/"dig", "smooth", "flatten"; strength 0..1. No-op when no terrain surface is near the point. Multiplayer: run on the server + mirror by RPC (deterministic ops).
terrain.slotAt
terrain.slotAt(x, y, z) — the texture-palette slot at a world point, or nil where the field is untextured. The material half of the question terrain.query answers the distance half of: survey before you cut, and let a footstep know what it is standing on.
terrain.warm
terrain.warm(bodyName) — keep that body's terrain RESIDENT this frame regardless of where the ship/player physically is: it streams in if cold and never streams out. Immediate mode — call every frame while you care (the map warms its focused planet). Streaming is otherwise anchored to dynamic bodies' physical positions, never the camera.
terrain.yields
terrain.yields() — drains what recent digs actually removed: { id, removed, added, untextured, slots }, with slots mapping palette slot to volume. This is how mining pays out by MATERIAL — you get ore because you cut rock that was painted as ore.
pathfinding — nav.*
nav
Pathfinding over the scene's navmesh — where characters can walk, and how they get anywhere. Bake one first: add a Nav Mesh node and press Bake. Everything here is in world coordinates.
nav.AREA_STRIDE
How many numbers nav.areas() uses per area (11). Read it rather than writing the number: fields are appended, never inserted, so code written against the constant keeps working.
nav.LINK_STRIDE
How many numbers nav.links() uses per link (8).
nav.agent
nav.agent(node[, opts]) — make this node something that walks the navmesh, and get a handle to order about. THE call for "move a unit from A to B": agent:moveTo(point), and it finds its own way, goes round its neighbours, slows down at the end and stops. Options, all optional: speed, accel, radius, arrive (how close counts as there), slow (where it starts easing off), avoid (take other agents into account), priority (who gives way), separation, repath (seconds between route checks), giveUpAfter (seconds of no progress before it reports blocked), drive ('auto' | 'transform' | 'velocity' | 'none'), and filter = { avoid = {'water'}, cost = { mud = 0.5 }, canDrop = false, canJump = false }. canDrop and canJump refuse the drops and jumps the bake found for itself — a turret, a cart or anything that does not leave the floor says no here rather than needing its own bake. drive defaults to 'auto': a node with a physics body is steered through the body, one without has its transform moved. The whole crowd is stepped once a frame by the engine, after your update — you never call a step function. ON A PROCEDURAL OR STREAMED LEVEL THE NAVMESH ARRIVES AFTER start(): there is no geometry at all when start() runs, so nav.ready() is false, and asking for the agent once behind that check means it is never made and every routing call silently takes your fallback for the rest of the session. Ask every frame until you have one — a script that handles 'no navmesh yet' gracefully handles 'no navmesh ever' identically, which is why this fails quietly.
nav.agents
nav.agents() — how many nav agents exist right now. For a HUD, a test, or checking that the ones you destroyed really went.
nav.areas
nav.areas() — every walkable area, as ONE FLAT ARRAY of numbers plus a count. Eleven numbers each, in nav.AREA_STRIDE steps: minX, minZ, maxX, maxZ, yMin, yMax, region, centreX, centreY, centreZ, ground — all world space, and ground is a one-based index into nav.ground(). Flat rather than a table per area on purpose: a real bake is thousands of areas, and a held Lua table costs one of a few thousand mlua slots, so a table each exhausts them and panics the editor rather than raising something a script could catch. One array costs one slot however big the level is. Read it as: local a, n = nav.areas(); for i = 0, n - 1 do local o = i * nav.AREA_STRIDE ... end
nav.budget
nav.budget([n]) — how many path searches the whole crowd may run per frame (default 8); returns the current value, and sets it when given a number. A hundred units given one order do not all think on the same frame: they queue, oldest first, and keep walking their old route while they wait. Raise it for a game where a burst of orders should be acted on at once, lower it if the searches show up in a frame graph.
nav.clearObstacles
nav.clearObstacles() — take every runtime obstacle away at once and give the whole level back, returning how many there were. For a wave ending or a level resetting, so nothing has to have kept a list of every crate.
nav.distance
nav.distance(from, to) — how far it is to WALK, in metres, or nil if there is no complete route. This is the number a decision should be made on: the straight-line distance to something on the far side of a wall is a lie, and "chase the nearest one" built on it picks the wrong one every time.
nav.ground
nav.ground() — the kinds of ground this bake knows about, as { {name, cost}, ... } in the order an area's ground numbers them. These are the names a filter says: avoid = {'water'} means something only because the level called an area that, and this is how a script finds out which names the level offers instead of guessing at one and having a typo read as nothing to avoid. Tables rather than a flat array, unlike its neighbours — a level has a handful of these where it has thousands of areas, and a name cannot be a number. nil with no bake.
nav.link
nav.link(name | id[, open]) — open or shut a Nav Link, or ask whether it is open. nil when there is no link by that name. This is the door: nav.link('front gate', false) makes every route that used it repath, nothing is rebaked, and a unit already halfway across finishes crossing rather than stopping in mid-air.
nav.links
nav.links() — every portal between two areas, as one flat array plus a count. Eight numbers each, in nav.LINK_STRIDE steps: from, to, leftX, leftY, leftZ, rightX, rightY, rightZ. from and to are ONE-BASED indices into nav.areas(); left and right are the portal's endpoints as somebody walking from from into to sees them, so a smoother never has to work out which side of itself it is on. Each portal appears once per direction.
nav.nearest
nav.nearest(point[, maxDistance]) — the closest walkable spot to a world point, or nil if there is none within range (default: the character's own height, so standing on top of the floor or half a step off a ledge is the ordinary case rather than a miss). Use it to drop a click, a spawn or a knocked-back character back onto the navmesh.
nav.obstacle
nav.obstacle(centre, size) — cut a box out of the baked navmesh, right now, and get a handle back. The crate dropped in a corridor: routes through that space stop existing, everything walking one repaths, and the level is not measured again. Hundreds of times cheaper than a rebake for a small thing on a big level — a 256 m level rebakes in ~460 ms and carves in ~0.6 ms. It is an OPTION and not a replacement: where the level genuinely changed shape (a building came down) the background rebake is the honest answer and this is not. The hole is grown outward to whole navmesh cells, so read ob.size rather than assuming you got the box you asked for. nil when the scene has no bake. There is deliberately no moving obstacle: carving every frame is rebuilding every frame, which is the cost this exists to avoid.
nav.obstacles
nav.obstacles() — how many holes nav.obstacle has cut in the navmesh right now. Zero with no bake.
nav.offLinks
nav.offLinks() — every off-mesh link in the level as data: { id, name, from, to, bidirectional, cost, duration, enabled, ground, kind, generated }, world space. kind is 'placed' for a Nav Link node somebody put there, or 'drop' / 'jump' for one the bake worked out from the shape of the floor; generated is the same fact as a boolean. Distinct from nav.links(), which is the thousands of portals the bake derived between neighbouring areas — two things called links is inherited and worth knowing before reading either. Use nav.link(name, open) to change one; this only reads. nil with no bake.
nav.onMesh
nav.onMesh(point[, tolerance]) — is this point on the walkable surface? The allocation-free version of nav.nearest, for the per-frame "am I still on the floor" check that does not want the point back. False when there is no navmesh at all, so it never raises.
nav.path
nav.path(from, to) — the corners to walk between two world points, as a list of vec3, plus a second return saying whether it REACHES the goal. Returns nil when an end is not on the navmesh at all (off the level, or inside a wall) — which is a different thing from a goal that is on the mesh but cut off, and that one comes back as a real route to the nearest reachable point with false alongside it. Walk it and stop is the right behaviour there; standing still because the answer was empty is not.
nav.random
nav.random(u, v[, near, radius]) — a point somewhere on the walkable surface, weighted by area so a big room is likelier than a corridor. The two numbers 0..1 are YOURS — call it as nav.random(math.random(), math.random()). That is deliberate: the engine rolls back and re-simulates, so a wander destination has to come from the same seeded stream as everything else the tick decided, and a navmesh that reached for its own randomness would desync every rollback that touched it. near and radius restrict it to a square neighbourhood (a square, not a circle — sampling a circle needs a re-draw, and there is no stream here to re-draw from).
nav.raycast
nav.raycast(from, to) — walk a straight line across the surface and get back where it stops, or nil if the whole line is walkable. The walker's answer rather than the collider's: a ledge this character would fall off is empty air to a physics ray and a wall to this. Use it to decide "can I just walk at it" before asking for a full path.
nav.reachable
nav.reachable(from, to) — can something actually walk from here to there? Different from nav.path(...) ~= nil: a path that exists but only gets partway comes back with false alongside it, and this is that flag. Cheaper than a path when the yes-or-no is all you wanted.
nav.ready
nav.ready() — whether this scene has a baked navmesh to ask. False is the ordinary state of a project that has not made one, not an error.
nav.rebake
nav.rebake(centre, size) — re-measure this box of the level and splice the answer into the navmesh, in the same frame. THE call for a level that builds itself: a streamer that has just finished a chunk, a generated room, a wall that came down. A full rebake measures the WHOLE level to account for one box, so the cost of building a chunk grows with how much level is already loaded — which is backwards, because the amount of new level per chunk is constant. This costs the box. Different from nav.obstacle: a crate standing on the floor is an obstacle and can be taken away again; a corridor that has just been built is a rebake and becomes the level. Carved obstacles survive it. It QUEUES like spawn (re-measuring needs the world's triangles, which the scripting side does not have) and lands in the same pass, after that pass's spawns and destroys — so build the chunk and ask in the same breath. World coordinates; the box is snapped outward to whole navmesh cells. Needs a navmesh already baked to splice into.
nav.regionOf
nav.regionOf(point[, tolerance]) — which walkable island a point is on, or nil if it is not on the navmesh. Two points in different regions can never be walked between, so comparing two ids rules out a search that was never going to succeed. The number itself means nothing beyond "the same one is the same island".
nav.sampler
nav.sampler([near, radius]) — the neighbourhood gathered ONCE, for many draws: local s = nav.sampler(node.position, 20); local p = s:point(math.random(), math.random()). nav.random re-gathers, re-sorts and re-measures every polygon its window covers on every call, so its cost grows with the radius — a dozen agents redrawing a destination in one frame was measured at 4 ms. A squad wanders around the same place, so hold the gather and each draw becomes a binary search. s.count is how many polygon parts the window covers; a count of 0 answers nil forever, which otherwise reads as bad luck. s:point(u, v) takes the same caller-supplied 0..1 pair nav.random does and for the same reason. A sampler is a SNAPSHOT: nav.splice re-baking the level does not reach one already built, so rebuild it when the ground changes.
nav.settings
nav.settings() — the character the mesh was baked for: radius, height, maxSlope, stepHeight, cellSize, maxDrop (the tallest ledge it steps off), maxJump (the widest gap it clears), minRegionArea (the smallest patch of ground kept), plus areaCount and area (square metres of walkable ground). A script moving a body along a path needs the radius the mesh was eroded by; guessing it is how a character ends up scraping the wall the erosion existed to avoid.
water — depth, buoyancy & ice
water
Water volumes: how deep a point is (water.depthAt), what is in the water (water.at), freezing and thawing (water.setFrozen). The engine already does buoyancy and drag — these are the questions a GAME still has to answer: swimming, drowning, flooding, a gauge going red.
water.at
water.at(point) — nil in air, else { depth, density, frozen, node, up }. up is the way OUT of the water (radial on a sea, the pool's own +Y) — what a swim controller pushes along, and NOT the same as -gravity in a tilted tank. Innermost volume wins, so a tank inside an ocean answers as the tank.
water.depthAt
water.depthAt(x, y, z) — or a vec3, or a node. Metres BELOW the surface at that point; 0 in air. The one number everything else is derived from, and it is the same rule the solver uses, so a swim state can never disagree with the physics that floats you. A frozen volume reads 0 everywhere.
water.isUnderwater
water.isUnderwater(point) — the yes/no, for when you don't need the depth. Takes x,y,z or a vec3 or a node: if water.isUnderwater(node) then stamina = stamina - dt end
water.setFrozen
water.setFrozen(node, true) — freeze a water volume. Freezing is a STATE, not a second system: the same node with a flag flipped, and both the physics (no buoyancy, no drag) and the look follow from it. A world that thaws is one call back.
water.volumes
water.volumes() — every body of water in the scene, as node handles. What a climate or weather system iterates when it wants to know where the seas are.
scatter — instanced props
scatter
Thousands of props from a seed — GPU-instanced, with no scene node anywhere in it. Your generator still decides WHAT grows where; the engine decides where each instance stands and draws them. scatter.create declares a source, scatter.remove harvests one.
scatter.cost
scatter.cost(id) — what this source asks for every frame: { chunks, props, far, chunkSize, perChunk }. Read it BEFORE you ship the field. The knobs look like a look, but the outermost lod distance is really the budget: it sets how many chunks stay resident, as a sweep whose side grows with it, walked every frame. Cost is about (far/chunk)^2 per source — halving the distance, or doubling the chunk, quarters it. A field big enough to matter also says so in the Console when you declare it. On a body smaller than your view distance the count saturates at the body, so a planet never costs more than a planet.
scatter.create
scatter.create{ asset = "tree.glb", seed = 7, perChunk = 24, chunk = 16 } — declare a source, get its id. Region: center + radius for a sphere (a planet), or center + halfX/halfZ for ground. parent = "Umunquo" anchors the region to a NODE, so a planet that orbits carries its props instead of sliding out from under them — every prop keeps its id, its place on the surface and the ground height it settled at, because none of those were ever expressed in world space. Without a parent the region is pinned to the world, which is right for a landscape that never moves and wrong for every celestial body. Also scaleMin/scaleMax, align = "surface" (default) or "world", fade, and lod = { {asset=, distance=}, ... } nearest-first. Placement is a pure function of the seed, so every machine and every session grows the SAME forest without storing one. density is how a world gets biomes: pass a function(x, y, z) -> 0..1 and it is sampled ONCE, at declare time, into a densityRows grid (rows x 2*rows for a sphere's longitude) — 0 means no instance is generated at all, not a hidden one. An option this doesn't list is an error, not a shrug. asset may be a mesh file OR a .prefab.ron — a prefab is baked once into one instanced draw per Mesh node it holds, each at its authored place in the prop, which is how a prop your own script assembled gets scattered.
scatter.destroy
scatter.destroy(id) — remove a whole source and everything it was drawing. Returns true if there was one.
scatter.near
scatter.near(sourceId, point, radius) — the instances around a point, nearest first: { id, pos, distance, scale, param }. What a harvest verb aims with, and what a "is there room to build here" check reads.
scatter.remove
scatter.remove(sourceId, instanceId) — take one prop out, permanently. By id rather than by position, which is what makes it survive streaming out and back in: an id comes from (seed, chunk, index), a position is a float off the end of a chain of arithmetic.
scatter.removed
scatter.removed(sourceId) — the sorted ids this source has lost. A game that wants permanence saves THIS — a handful of numbers — not every plant it ever saw, which is what made permanence unstorable before (save values are capped at about a kilobyte).
scatter.restore
scatter.restore(sourceId [, instanceId]) — put one prop back, or all of them when the instance is omitted (returns how many). This is what "the forest regrows after fifteen minutes" is, without your game having to remember what it cut.
2D — sprites, sorting & the flat camera
EMPTY_TILE
EMPTY_TILE — the tilemap cell value that leaves a square empty (u32::MAX, 4294967295). Prefer -1: any negative cell means empty, which is the convention in Tiled, Godot and LDtk. This constant exists because the API documented the name long before Lua could resolve it.
batch:draw
b:draw(x, y [, z] [, scale] [, rot] [, cell] [, r, g, b, a]) — draw one sprite THIS FRAME, positioned in the batch node's local space. Immediate mode, exactly like draw.* : what you draw this frame is what shows, and next frame starts empty — there is no pool to grow and no clear() to forget. scale is one number, or a vec2 for squash-and-stretch: b:draw(x, y, 0, vec2(1.4, 0.6)). The tint is the thing a shared Material could never give one sprite: flash one enemy red without blinking it off.
node:setCamera2D
node:setCamera2D{follow="Player", smoothing=0.12, deadZoneX=1.5, deadZoneY=0.75, limits=true, minX=0, minY=0, maxX=200, maxY=40, pixelSnap=32} — how this ORTHOGRAPHIC camera follows. Every key is optional and keeps what the node had, per axis; off=true removes the behaviour. The order is dead zone, then smoothing, then limits: the camera does not move until the target leaves the box, closes the rest exponentially (smoothing is SECONDS to cover about two thirds of the gap, the same at 30fps and 144), then clamps inside the rectangle so it never shows outside the level. Setting follow to a DIFFERENT node restarts the follow where the camera is, so handing the camera to a second character does not send it travelling between them; follow="" stops following and keeps the limits, and with no target the camera's position is left to whatever else is moving it. pixelSnap is pixels per world unit and lands the DRAWN camera on a whole pixel of that grid — the same number a Sprite's ppu uses, and what camera.pixelsPerUnit() answers; 0 turns it off. Without it a camera that stops between two pixels resamples every sprite by a fraction of one and pixel art shimmers along its edges while nothing is moving; the follow keeps its sub-pixel place, so the camera can still creep slower than a pixel a frame. It does nothing on anything that is not an orthographic camera.
node:setParallax
node:setParallax{x=0.3, y=1} — how much of the camera's movement this layer KEEPS, per axis. 1 moves with the world (no parallax, and the default), 0 pins it to the camera as if infinitely far away, 0.3 is a distant range of hills. Both keys optional and both keep what the node had. This exists because the other way of getting parallax — putting a layer further back in Z — only works under a PERSPECTIVE camera, and a flat game wants an orthographic one so its pixels-per-unit holds still; a scroll factor works under either. Like a sorting layer it offsets the DRAWN transform only, so the collider stays where you put it and node.x reads back what you set.
node:setSorting
node:setSorting{layer="Terrain", order=3} — where this 2D node draws in the stack. layer is one of the project's sorting layers by name; order places it within that layer, higher being nearer the camera. Both optional and both keep what the node had. This is how a character steps behind a counter, or a picked-up card lifts above the hand.
node:setSprite
node:setSprite{ppu=32, size=1, cell=0, flipX=false, flipY=false, pivotX=0.5, pivotY=0} — make this node one sprite, or retune one. Every key is optional and keeps what the node had, including one pivot axis without the other. ppu is pixels per unit measured against ONE CELL of the Material's sheet, so re-slicing a sheet finer does not resize every sprite on it; ppu=0 falls back to size, a world edge length. flipX/flipY mirror the picture and not the node, so children and normals are left alone. pivotY=0 puts the origin at the sprite's feet, which is what a Y-sorted character wants — sorting reads the node's Y, and a centred origin sorts by a point floating at the character's waist.
node:setSpriteBatch
node:setSpriteBatch{size=1.0} — make this node a SPRITE BATCH, so node:sprites() can draw into it. The counterpart of node:setTilemap: a game's sprite styles are data (one batch per material), so the nodes that draw them are made from the same script that declares them rather than authored one at a time into the scene. size is the quad's edge length; every sprite scales it. The sheet is the node's own Material.
node:setTilemap
node:setTilemap{cols=13, rows=7, tile=1.5 [, data={…}] [, tileset="tilesets/bricks.tileset.ron"]} — make this node a TILEMAP: a grid of spritesheet cells drawn as one mesh, one draw call. The sheet is the node's own Material (texture + sheetCols/sheetRows). Neighbouring tiles share an exact edge, so the hairline gaps a grid of separate quads opens up as the camera moves cannot happen. data is row-major from the top-left; leave it out for an empty grid you fill with tm:set.
node:shake
node:shake(amount, seconds) — shake a 2D camera. amount is a distance in world units, seconds defaults to 0.3, and it fades out over that time. Added to what is DRAWN and never fed back into the follow, so it composes with a chase and with the world limits instead of fighting them — a shake at the edge of a level still shakes, and a camera being driven by a script keeps being driven by it. Calling it again takes the LOUDER amplitude and the LONGER time, each independently, so shaking every frame while something explodes cannot build an unbounded shake and a bang cannot cut a long rumble short. It is a function of the play clock, not of a random number, so two machines simulating the same frame see the same camera. On anything that is not an orthographic camera it does nothing.
node:sorting
node:sorting() -> { layer =, order =, mode = } — where this node sits in the 2D stack. A node that has never said anything about sorting answers with the DEFAULT ("Default", 0, "order") rather than nil, because that IS the true answer for it and nil would make every caller write the same three lines of fallback before it could add one to a number.
node:sprite
node:sprite() — this node's Sprite component as a handle you can read AND assign: local sp = node:sprite(); sp.flipX = mx > 0. Fields: flipX, flipY, cell, ppu, size, pivotX, pivotY. Writes land on the component the renderer reads (and the Inspector shows) after the frame, and read back straight away, so the flag a script sets is the flag it can ask about on the next line. Singular: this is the ONE sprite this node draws. node:sprites() (plural) is the batch handle, for a node that draws many. On a node that is not a Sprite this is an error naming what to do about it, not a handle whose writes go nowhere.
node:sprites
node:sprites() — a handle to this node's SpriteBatch (make it one with node:setSpriteBatch{} first; on any other node this is an error rather than a handle that silently draws nothing): b:draw(...) queues one sprite for this frame. N sprites from one node, each with its own position, rotation, scale, cell AND tint — no scene node per sprite and no pool to grow.
node:tilemap
node:tilemap() — a handle to this node's tilemap grid. Squares: tm:set / tm:get / tm:at / tm:fill / tm:fillRect / tm:size / tm:resize. World space: tm:cellAt (which tile is the player standing on) / tm:worldAt / tm:tileSize. What a tile IS, from the node's tileset: tm:solid / tm:tags / tm:hasTag / tm:autotile.
sp.cell
sp.cell — which cell of the Material's spritesheet draws, 0-based. An animation clip's Sprite ▸ frame lane writes this too, so a script that also sets it every frame is the one that wins.
sp.flipX
sp.flipX — mirrored left-to-right. The one line behind a character facing the way it walks: sp.flipX = mx > 0. Reads back as a BOOLEAN.
sp.flipY
sp.flipY — mirrored top-to-bottom.
sp.pivotX
sp.pivotX — where the node's origin sits across the sprite, 0..1 (0.5 = centred). Outside 0..1 is allowed: an origin off the sprite is a legitimate thing to want.
sp.pivotY
sp.pivotY — the origin up the sprite. 0 puts it at the sprite's feet, which is what a Y-sorted character wants; setting one axis leaves the other alone.
sp.ppu
sp.ppu — pixels per world unit, measured against ONE CELL of the sheet: it is what makes a 16-pixel tile exactly one unit wide. 0 means "size me by size instead".
sp.size
sp.size — the sprite's world edge length, used when ppu is 0. For art that is not pixel art.
tm.EMPTY
tm.EMPTY — the cell value that means "no tile here", on the handle rather than only as a global. Same number as EMPTY_TILE; -1 and nil mean it too.
tm:at
tm:at(x, y) → cell, rot, flipX — the WHOLE answer for a square, where tm:get gives only the cell. rot is degrees clockwise (0/90/180/270). For art that faces a direction: a conveyor, a pipe, a one-way platform.
tm:autotile
tm:autotile(x0, y0, x1, y1) — recompute the region's autotiled squares, plus the one-square ring around it (which is where the stale edge tiles are). Call it after a run of tm:set, not per square: retiling per write would be O(area) each time and would fight a stroke still being laid down. Does nothing when the map has no tileset.
tm:cellAt
tm:cellAt(worldPos) → x, y — which square a WORLD position falls in, or nil off the map. Takes a vec3, an {x=,y=,z=} table, or a node. Goes through the tilemap node's own transform, so a map that has been moved, turned or scaled still answers correctly — which is the part a game cannot reasonably compute itself.
tm:fill
tm:fill(cell) — set every square, including the empty ones. The fast way to reset a room before re-dressing it. tm:fill() with no argument, tm:fill(-1) and tm:fill(EMPTY_TILE) all clear the grid.
tm:fillRect
tm:fillRect(x0, y0, x1, y1, cell [, xform]) — fill a rectangle. Corners in either order, clipped to the grid, so dragging past the edge fills to the edge.
tm:get
tm:get(x, y) → cell, or nil outside the grid and on an empty square.
tm:hasTag
tm:hasTag(x, y, "ice") → the common case of tm:tags without allocating a table per square. What a per-frame ground check should call.
tm:resize
tm:resize{ cols =, rows =, offsetX =, offsetY = } — resize the grid, keeping whatever overlaps. offsetX/offsetY is where the OLD top-left lands in the new grid, so offsetY = 1 grows a row on top rather than at the bottom. Give at least one of cols / rows.
tm:set
tm:set(x, y, cell) — set one square, 0-based from the TOP-LEFT. Outside the grid is a no-op rather than a wrap. To clear a square pass -1 (any negative works, as in Tiled/Godot/LDtk), nil, or the EMPTY_TILE constant — all three are the same value. A cell that is not a whole number in range is an error naming what it got and what it accepts, never a neighbouring tile.
tm:size
tm:size() → cols, rows.
tm:solid
tm:solid(x, y) → whether the tileset says that square collides. False on an empty square and false with no tileset. Reads the TILESET, so marking one brick solid answers for every brick in every scene — a game keeping its own table of solid cell indices goes stale the day the artist reorders the sheet.
tm:tags
tm:tags(x, y) → the tileset's tags for that square, as a list. This is how a tilemap carries gameplay ("ice", "water", "damage") without the game keeping a second table keyed by cell index.
tm:tileSize
tm:tileSize() → the world edge length of one square. What tm:cellAt divides by, and what a game placing something on a tile needs.
tm:tileset
tm:tileset() → the project-relative .tileset.ron this map is cut from, or nil. The tileset is what says whether a tile collides, what it is tagged, and how it autotiles — see docs/tilemaps.md.
tm:worldAt
tm:worldAt(x, y) → the world position of that square's CENTRE (a vec3), or nil off the grid. The centre and not a corner, because what you do with it is put something on the tile.
vessels — assembly.*
assembly
Multi-part vessels: hold forces and torques, split parts off, latch parts on, and read the compound's mass and centre of mass. A vessel is one physics body built from many nodes.
assembly.force
assembly.force(node, force) — a HELD force through the centre of mass, re-applied every tick until you change it (engines, thrusters). Through the CoM means no torque: the vessel accelerates without turning.
assembly.forceAt
assembly.forceAt(node, force, at) — a held world-space force at a world point. Off the centre of mass it produces torque as well as acceleration, which is how an off-axis thruster makes a craft tumble — and how RCS steers it.
assembly.impacts
assembly.impacts(node) — the LAST tick's per-part contact loads: { part, impulse, speed, speedAbs, x, y, z }. What a damage model reads: how hard each part was hit and where.
assembly.impulseAt
assembly.impulseAt(node, impulse, at) — a one-shot kick at a world point, applied once rather than held. Explosions, collisions you resolve yourself, a docking clamp letting go.
assembly.info
assembly.info(node) — { mass, com, origin, vel, angVel, grounded, anchored, parts }. com is the world-space centre of mass as a vec3 — the number a flight controller, a CoM gizmo and a landing check all need.
assembly.keepLive
assembly.keepLive(node, true) — exempt this compound from distant-craft LOD, so it keeps simulating in full even when nothing is near it. For the craft the player will come back to and expects to find where physics would have put it.
assembly.merge
assembly.merge(node, other) — latch another assembly onto this one: docking, grabbing, a part snapping into place. The two become one physics body with one mass and one centre of mass.
assembly.rebuild
assembly.rebuild(node) — re-gather the compound from the root's CURRENT children. Call it after you have added or removed part nodes yourself, so the physics body matches the scene again.
assembly.setAnchored
assembly.setAnchored(node, true) — pin the vessel exactly where it stands (a launch clamp, a craft on a pad, anything that must not drift while you build it). Release it and normal physics resumes.
assembly.split
assembly.split(node, parts [, fn] [, prefab]) — detach part nodes into their own assembly (stage separation, a wing coming off). The new assembly keeps the velocity it had, so debris carries on rather than appearing at rest.
assembly.syncColliders
assembly.syncColliders(node) — re-pose the compound's collision shapes to its parts' current transforms. Needed after you move parts around without a rebuild, or the vessel collides with where it used to be.
assembly.teleport
assembly.teleport(node, pos) — move the assembly origin to a world position, carrying every part with it. A teleport rather than a force: no acceleration, no tumble.
assembly.torque
assembly.torque(node, t) — a held PURE torque, no linear push: reaction wheels, SAS, anything that turns a vessel without moving it.
the camera & the screen
camera
The game camera's projection: viewport size and rect, world↔screen conversion, and picking rays. camera.screenRect shares its space with input.mouse(), which is why hit-testing works.
camera.exists
camera.exists() — true once a live game camera is being fed. Guard the other camera.* calls with it during the first frames, or while a scene without a camera is up. True throughout a floptle shot, which feeds its picture's size; never under floptle run, which draws nothing.
camera.pixelsPerUnit
camera.pixelsPerUnit([distance]) → px — how many screen pixels one world unit covers. The number every 2D game used to derive by hand from the FOV and the camera's Z, and then snap the camera to a multiple of for crisp pixels.
Under an ORTHOGRAPHIC camera the answer is the same everywhere and distance is ignored — that is what an orthographic projection means, and it is the case a flat game is in. Under a perspective one it is measured at distance, defaulting to the camera's distance from the origin.
A 2D camera can do the snapping for you: node:setCamera2D{ pixelSnap = 32 }.
camera.screenRect
camera.screenRect() -> x, y, w, h — the game viewport in the SAME space as input.mouse() and camera.worldToScreen, offset included. That shared space is the only reason hit-testing the mouse against a projected point works; screenSize alone would be wrong wherever the viewport isn't at the window origin. Under floptle shot --size WxH it is 0, 0, W, H from the first update — the picture is the viewport — so a layout can be checked at 1440p, an ultrawide or a portrait window without a window; floptle run renders nothing and reports 0, 0, 0, 0.
camera.screenSize
camera.screenSize() → w, h — the game viewport size in pixels. camera.exists() is true once a live game camera is being fed. Under floptle shot this is the picture's --size from the first update, so a HUD can be checked at any screen size headless; floptle run renders nothing and reports no camera.
camera.screenToRay
camera.screenToRay(sx,sy) → ox,oy,oz, dx,dy,dz — a world ray from a screen pixel (inverse of worldToScreen).
camera.worldToScreen
camera.worldToScreen(x,y,z) → sx, sy, depth, onscreen — project a world point into the game view (pixels in input.mouse()'s space). onscreen=false behind the camera / off-frustum. Sample a drawn line into points, project each, keep the nearest to the cursor = click-on-line picking (the map's maneuver nodes).
physics controls — pause & step
physics
Sim controls: physics.pause(true) freezes the whole gameplay tick while scripts keep running (pause menus, cutscenes, loading screens), and physics.step() advances it one tick at a time.
physics.isPaused
physics.isPaused() — whether the sim is currently frozen, including when the editor froze it rather than your script.
physics.pause
physics.pause(true) — freeze the whole gameplay tick while scripts keep running. Pause menus, cutscenes and loading screens are this call: the world stops, your UI doesn't.
physics.step
physics.step([n]) — advance the frozen tick n times (default 1, max 600) — the same thing the editor's frame-step button does, so a game can build its own training mode. Call it from update: a fixedUpdate caller would never get a second turn, because the tick it is waiting for is the one it just stopped.
frame cost — perf.*
perf.accountedMs
perf.accountedMs() — the buckets added up. Called 'accounted' and not 'total' on purpose: vsync, the OS and the GPU finishing are outside every bucket, so this is what the engine can see, not the frame time.
perf.buckets
perf.buckets() → the bucket names, in frame order: scripts, mirror, physics, terrain, scatter, particles, audio, animation, ui, render. Iterate this rather than keeping your own list, which could go stale. scripts is the WHOLE of every script pass — setup, reference params, write flush and hooks; mirror is the ECS-to-Lua sync each pass runs first, nested inside a pass and subtracted out of scripts so nothing is counted twice.
perf.counts
perf.counts() → { nodes=, culled=, instances=, draws=, chunks=, props=, particles=, effects=, effectsDropped=, lights=, lightsDropped=, voices= }. Readable even while collection is off, because counts are free to keep — and three of the four 'the engine is slow' reports this API exists for were answerable from one count alone (a scatter field asking for 117,000 props was one of them). The *Dropped pair is what a ceiling refused this frame: nonzero means the engine is cutting your look, which you should hear from a number rather than from a screenshot.
perf.enable
perf.enable(true) — start collecting; perf.enable(false) stops and CLEARS the history (a stale average from before a fix looks exactly like a fix that did not work). Off by default, because a profiler that costs a frame is one people turn off.
perf.enabled
perf.enabled() — is anything being measured? Safe to call while off, so a script can ask before reading.
perf.ms
perf.ms("scripts") — that bucket's rolling average, in milliseconds. An unknown bucket names every accepted value rather than answering 0.
perf.scriptMs
perf.scriptMs("planet_walker") — one script's own average cost, by file name. 0 for a script that has not run, which is different from an error. This is HOOK time: the per-script figures are a breakdown of part of perf.ms("scripts"), not all of it, and the difference is what the engine spends reaching a hook.
perf.scriptWorstMs
perf.scriptWorstMs("planet_walker") — that script's worst frame in the last second.
perf.scripts
perf.scripts() → { {name=, ms=, worstMs=}, ... }, MOST EXPENSIVE FIRST — which is the order the question is asked in. A total for 'scripts' never answered 'which of my scripts is doing this'.
perf.slowestScript
perf.slowestScript() → the name of the costliest script, or nil if none have run. The one-liner you actually put in an assertion message.
perf.worstMs
perf.worstMs("scripts") — the WORST single frame in the last second. This is the one to watch: a 40 ms hitch once a second adds under a millisecond to a 60-frame average, so the mean hides exactly the thing you are chasing.
accessibility — access.*
access.captions
access.captions() → is the player showing captions?
access.colorFilter
access.colorFilter() → the active colour-vision filter's name ("none" / "protanopia" / "deuteranopia" / "tritanopia").
access.colorFilterStrength
access.colorFilterStrength() → how strongly the colour filter applies, 0–1.
access.filters
access.filters() → { {name=, label=}, … } — every colour filter in menu order, so an options dropdown does not hard-code a list that can go stale. label is the human one ("deuteranopia (green-blind)").
access.reducedMotion
access.reducedMotion() → the player asked for less movement. The engine already snaps its OWN UI transitions; read this for the motion it cannot know about — your camera shake, screen flashes, big animated wipes. The engine cannot tell which of your movement is the game.
access.setCaptions
access.setCaptions(true) — turn captions on. While off, caption(...) draws nothing, so a game writes caption() beside the sound and never an if around it.
access.setColorFilter
access.setColorFilter("deuteranopia" [, strength]) — correct the picture for a colour vision deficiency, as a stage in the post chain (so it applies to everything the player sees, and a scene cannot veto it by disabling its PostProcess node). strength 0–1; full correction shifts hues a lot and some players want less. An unrecognised name raises naming the four it takes — a misspelled filter that quietly meant "off" is an accessibility setting that appears to do nothing.
access.setReducedMotion
access.setReducedMotion(true) — ask for less movement. UI transitions SNAP rather than hurry (a 40 ms slide is still a slide).
access.setTextScale
access.setTextScale(1.5) — set the UI text multiplier, 0.5–3.0. This is the single most-used accessibility setting in games. Out of range RAISES rather than clamping: a settings slider hands over a number it already bounded, so a value outside it means the caller computed it wrong. Persist it yourself with save.set — it is the player's setting, so it belongs in the player's save.
access.textScale
access.textScale() → the player's UI text multiplier (1.0 = normal). Every UI text size is multiplied by it BEFORE layout, so text scaling reflows — a fit-height box grows and its neighbours move down — rather than painting bigger glyphs into the same rect and clipping.
caption
caption("a door unlocks somewhere" [, seconds]) → true if it was shown. Says a line the engine draws bottom-centre on a dark plate, at the player's text scale, oldest first — so every game gets the same readable placement instead of hand-rolling one. A no-op (returning false) while access.captions() is off. Without seconds the duration suits the length of the line.
persistence — save.*
save
The persistent store: save.set / save.get, named slots, and flushing to disk. Values are capped at about a kilobyte each — store the small fact, not the whole world.
save.delete
save.delete("gold") — remove a key; true if something was removed.
save.deleteSlot
save.deleteSlot("slot2") — delete a slot's store file from disk ("delete this save" UIs). Deleting the ACTIVE slot also empties the in-memory store, so the slot is instantly reusable. Per-slot terrain is separate — pair with terrain.deleteSaveDir. Returns true if a file was removed.
save.flush
save.flush() — write the store to disk NOW (checkpoints, before risky sections). Returns false on an IO error (also shown in the Console).
save.get
save.get("gold" [, default]) — the stored value, else the default, else nil. save.get("who").hp reads into stored tables.
save.set
save.set("gold", 42) — store persistent game data: survives Play sessions, editor restarts, and ships with exported builds. Values follow the synced-var guardrails (numbers/strings/bools/tables, depth <= 4, <= 1 KB), and a slot holds at most 10 000 keys / 4 MB in all. Flushed on Stop + every few seconds during Play.
save.set("hp", hp) -- survives scene loads and quits
hp = save.get("hp", 100)
save.slot
save.slot("slot2") — switch the active save slot (the old one flushes first); save.slot() reads the current name. Each slot is its own file under save/.
timers — after, every, tween
after
after(seconds, fn) — run fn once after that much GAME time (tick-driven, deterministic, pauses with the game). Returns a handle: h:cancel() aborts. Capture what you need as locals — the callback gets no arguments. after(2, function() door.visible = false end)
after(0.25, function() spawnEffect("Explosion", node.pos) end)
every
every(seconds, fn) — run fn repeatedly (first fire after one period). Anchored cadence: long sessions don't drift. Keep the handle to stop it: local h = every(1, tickDown) ... h:cancel().
-- a heartbeat that survives long sessions without drifting
every(1.0, function() hp = math.min(hp + 1, 100) end)
timer:cancel
timer:cancel() — stop a pending after / every / tween. The handle those three return exists for exactly this: local h = every(1, tick) ... h:cancel().
tween
tween(seconds, fn [, ease]) — animate: fn(alpha) runs every tick with alpha easing 0→1, final call exactly at 1.0. ease: "linear" (default), "smooth", "in", "out". tween(0.5, function(a) node.y = startY + a * 3 end, "smooth"). Returns a cancellable handle.
-- SECONDS first, then the function; alpha eases 0 -> 1 and lands on 1.0
tween(0.4, function(t) node:getcomponent("UiElement").opacity = t end, "smooth")
space — orbits & time-warp
body.mu
Gravitational parameter µ = GM.
body.name
The celestial body's node name — what space.body() takes and space.dominant() returns.
body.radius
Physical surface radius.
body.soi
Sphere-of-influence radius (-1 = infinite, the root).
body.vx
World velocity X — the body's own motion along its rails, which a rendezvous has to match.
body.vy
World velocity Y.
body.vz
World velocity Z.
body.x
World X of the body's centre this tick.
body.y
World Y of the body's centre.
body.z
World Z of the body's centre.
space
On-rails celestial mechanics: where the bodies are, which one's gravity owns a point, the orbit a craft is on, and time-warp.
space.bodies
space.bodies() — every celestial body this tick: {name, x,y,z, vx,vy,vz, mu, radius, soi} in world coords (soi -1 = infinite). space.body("Pebble") grabs one by node name.
space.body
space.body("Pebble") — one celestial body by node name: { name, x,y,z, vx,vy,vz, mu, radius, soi } in world coordinates, or nil. space.bodies() returns them all.
space.dominant
space.dominant(x, y, z) — the name of the body whose gravity OWNS that position (deepest sphere of influence — the moon inside the planet inside the sun), or nil.
space.elements
space.elements(x,y,z, vx,vy,vz) — the orbit a craft is ON around its dominant body: { body, a, e, periapsis, apoapsis, period } (apoapsis/period absent on an escape). Distances from the body CENTER. The map/HUD readout.
space.gravity
space.gravity(x, y, z) — gx, gy, gz: the µ/r² pull of the dominant body at a world position (patched conics: exactly one body pulls).
space.propagate
space.propagate(px,py,pz, vx,vy,vz, mu, dt) — the state (px,py,pz, vx,vy,vz) advanced dt seconds on the two-body conic about a point mass mu (elliptic OR hyperbolic, drift-free). The map's maneuver nodes + SOI-encounter walk are built from it. State is in whatever frame you pass — compose parent frames yourself.
space.time
space.time() — on-rails celestial time in seconds (0 at Play start; advances with warp). Scenes with Celestial Body components put planets/moons on exact Kepler rails.
space.warp
space.warp(50) — request a time-warp multiplier (1 .. 100000): rails fast-forward, local physics keeps ticking at 1×. space.warp() reads the current value.
components — getcomponent
cam.active
The play-mode view camera (1/0) — assign true to switch to it.
cam.fovY
Vertical field of view, radians.
env.ambient2d
find("Lighting"):getcomponent("Light") — the scene's Lighting node, read and written like any other component. THIS IS WHERE A 2D SCENE'S BRIGHTNESS LIVES: ambient2dR/G/B is the 2D base light, the whole light a flat scene has before a single 2D light is placed, so turning it down is how you get a dark room for a torch to carve a circle out of — and reading it back first is how you put it where it was. Also colorR/G/B + intensity + directionX/Y/Z (a day cycle), ambientR/G/B (the 3D fill, deliberately a different value), shadows/shadowSoftness/shadowStrength/shadowTintR/G/B/shadowQuantize/shadowDither/shadowDistance/contactShadows/contactLength/contactSteps/contactStrength, the screen-space reflections that make a shiny floor show the room standing on it rather than only the sky (reflections, reflectionDistance, reflectionSteps, reflectionThickness), reflectionClamp (the most one reflected bounce may carry — two mirrors facing each other re-reflect each other every frame and a polished metal loses almost nothing per pass, so without a ceiling the pair climbs into a white blob; 0 removes it), refractionLayers (how many depths of glass can be seen through at once — at 1 only the nearest pane shows what is behind it, so a fish tank has to be one box; raise it and a window can have a bottle standing behind it), and the whole fog set: fog, fogColorR/G/B, fogStart, fogEnd, fogDensity, fogHeight, fogFalloff, fogNoise, fogNoiseScale, fogVolumetric, fogDither, fogDitherStrength, and the volumetric light injection (fogLight, fogAnisotropy, fogSteps, fogShafts). Every scene has exactly one Lighting node and the loader makes it, so find("Lighting") always finds it. Writes land the same frame.
env.ambient2dB
The 2D base light, blue 0..1. See ambient2dR.
env.ambient2dG
The 2D base light, green 0..1. See ambient2dR.
env.ambient2dR
The 2D BASE LIGHT, red 0..1 — the whole light a flat scene has before any 2D light is placed. White by default; turn it down for a dark room a torch can carve a circle out of, and read it back first so you can put it where it was.
env.ambientB
3D ambient fill blue 0..1.
env.ambientG
3D ambient fill green 0..1.
env.ambientR
3D ambient fill red 0..1 — the fill under the key light, deliberately a different value from ambient2dR.
env.colorB
Key light colour blue.
env.colorG
Key light colour green.
env.colorR
Key light colour red.
env.contactLength
How far a contact shadow traces, in world units. Short is the point — the shadow under a foot, in a seam, behind a bolt.
env.contactShadows
The small dark line where things touch (1/0). A moving mesh casts through its COLLIDER, so a character's shadow is a capsule's — this shadows from the real silhouette of whatever is on screen. Only what is ON SCREEN casts one.
env.contactSteps
Samples along the contact trace (2..32). Raise it if the shadow looks striped.
env.contactStrength
How dark a contact shadow gets, 0..1, before the shared shadow tint and strength.
env.directionX
Key light direction X — lerp the three for a day cycle.
env.directionY
Key light direction Y.
env.directionZ
Key light direction Z.
env.fog
Depth fog on (1/0; assign true/false).
env.fogAnisotropy
Volumetric: which way the media throws light (-0.9..0.9). Positive blooms toward the sun, 0 is an even haze. Fog has no normal — this is what does that job.
env.fogColorB
Fog colour blue.
env.fogColorG
Fog colour green.
env.fogColorR
Fog colour red — match it to the horizon or a seam shows.
env.fogDensity
Volumetric: media density per world unit.
env.fogDither
Dither the fog gradient to hide 8-bit banding on long ramps (1/0).
env.fogDitherStrength
Dither amplitude 0..1.
env.fogEnd
World distance where fog is full.
env.fogFalloff
Volumetric: softness of the layer's top edge, world units.
env.fogHeight
Volumetric: world height (y) of the fog layer's top.
env.fogLight
Volumetric: how much of the scene's light scatters IN the fog. 0 = a flat colour; 1 = lit by the sun, the point lights and the baked bounce; past 1 exaggerates. Ramp it up as a storm rolls in and the air itself starts carrying the light.
env.fogNoise
Volumetric: how much drifting noise breaks up the media, 0..1.
env.fogNoiseScale
Volumetric: noise feature size, world units per repeat.
env.fogShafts
Volumetric (1/0): march the sun shadow at every fog step, so beams appear through windows and branches. The entire cost of lit fog lives here.
env.fogStart
World distance where fog begins (fully clear nearer than this).
env.fogSteps
Volumetric: samples along each pixel's fog ray (2..64). The quality/cost dial — drop it on a weak machine.
env.fogVolumetric
Volumetric mode (1/0): march real fog media instead of a distance ramp, so hills poke out of ground mist. fogStart/fogEnd do not apply.
env.intensity
Brightness multiplier on the key (directional) light.
env.shadowDistance
Max world distance a shadow ray marches before giving up; far geometry stops casting past it.
env.shadowDither
Bayer-dither the penumbra (1/0) — the classic PS1 dithered shadow edge.
env.shadowQuantize
0 = smooth penumbra; 2..8 = posterize it into that many bands (toon/retro).
env.shadowSoftness
0 = razor-hard edge … 1 = dreamy-soft penumbra.
env.shadowStrength
How dark full shadow gets, 0..1 (ambient still fills, so never pitch black).
env.shadowTintB
Shadow tint blue.
env.shadowTintG
Shadow tint green.
env.shadowTintR
Shadows darken toward this colour instead of black — red.
env.shadows
Sun shadows on (1/0; assign true/false). Every shadow field below only applies when this is on.
env.stars
Stars mode (1/0; assign true/false): luminous celestial bodies ARE the key lights.
light.b
Color blue 0..1.
light.g
Color green 0..1.
light.height
Rect only: its height in world units.
light.intensity
Brightness multiplier.
light.length
Tube only: how long the bar is — a long one streaks its highlight along itself.
light.r
Color red 0..1.
light.radius
Sphere / disk only: its radius in world units.
light.range
Reach in world units.
light.shape
The surface it emits from: 0 point, 1 sphere, 2 rect, 3 disk, 4 tube. A rect and a disk face the node's FORWARD and a tube lies along its local X, so a light with a shape is aimed by rotating the node. Assigning keeps the size it had, so cross-fading a window into a bulb does not flash.
light.thickness
Tube only: how thick the bar is.
light.twoSided
Rect / disk only (1/0): lights out of the back as well as the front. Off is a window; on is a floating panel.
light.width
Rect only: its width in world units. Reads 0 on a shape that has no width.
mat.alpha
mat.alpha — opacity, 0..1. Also readable as mat.opacity.
mat.cell
Which cell of the sheet draws (row-major from the top-left; clamped into the grid).
mat.color
mat.color — the tint, MULTIPLIED into the texture: white leaves the picture alone, and a colour tints it. Takes a color(r, g, b) or any {r,g,b} table; reads back as a colour. The per-channel spellings mat.r / mat.g / mat.b are the same value, for animation lanes that key one number.
mat.emissive
mat.emissive — light this surface gives off, scaled by emissiveStrength. A colour; the channels are also mat.emissiveR/G/B.
mat.emissiveStrength
mat.emissiveStrength — how much light emissive gives off. 0 turns it off however bright the colour is.
mat.fog
mat.fog — does the scene's fog reach this surface? false keeps a UI panel or a skybox plane out of the weather. Reads back as a BOOLEAN.
mat.metallic
mat.metallic — 0 is a dielectric, 1 is bare metal. For a metal the ALBEDO is the reflection tint, so a black metal reflects nothing.
mat.metallicMap
mat.metallicMap — per-pixel metalness. "" clears it.
mat.normalMap
mat.normalMap — the surface's bump directions. "" clears it.
mat.occlusionMap
mat.occlusionMap — baked ambient occlusion. "" clears it.
mat.rim
mat.rim — the colour of the rim light around its silhouette (channels: mat.rimR/G/B).
mat.roughness
mat.roughness — 0 is a mirror, 1 is chalk.
mat.roughnessMap
mat.roughnessMap — per-pixel roughness. "" clears it.
mat.sheetCols
Sheet columns (0 = not a sheet — the whole texture).
mat.sheetRows
Sheet rows.
mat.specular
mat.specular — the colour of its highlight (channels: mat.specularR/G/B).
mat.texture
mat.texture — the base-colour image, project-relative ("art/shirt.png"). Assigning swaps what the surface wears; "" clears it back to a flat colour. Reads back what you last set.
mat.unlit
mat.unlit — draw at full brightness, ignoring every light. Reads back as a BOOLEAN.
mat:setShaderParam
mat:setShaderParam("glow", 2.5) / mat:setShaderParam("nose", x, y, z) — drive a .flsl uniform on THIS material every tick: the node's own for node:material(), ONE PART's override for node:material("Head#2"). The node-level node:setShaderParam folds into the node's own Material and cannot reach a part, which is what this exists for — a character whose parts wear shaders (skin here, a face decal there) can now have a part's knobs driven at runtime. A GPU uniform write, never a recompile; unset lanes are 0. The write lands only on a part that already HAS an override wearing a .flsl — it never creates one (an override is a whole material, and a uniform must not blank a part), and a part with nowhere to put it is said once in the Console.
mat:setShaderTexture
mat:setShaderTexture(slot, ref) — point one of THIS material's .flsl texture slots somewhere else, at runtime — on one part of a model when the handle came from node:material("Head#2"). slot is the name the shader declares (texture face -> "face"); ref is a project-relative image path, an rt:<name> render target, or "" to clear it. The character creator's face swap is this one line: node:material("Head#2"):setShaderTexture("face", "faces/02.png"). Same rule as setShaderParam: it lands on an existing override that wears a shader, and never creates one.
mat:shaderParam
mat:shaderParam("glow") -> x, y, z, w — read a uniform back off this material: what you set this frame, else what the material carries. nil when it has no such knob. Reads in the same frame as the write, the way mat.color does.
mat:shaderTexture
mat:shaderTexture(slot) -> ref — which image a texture slot points at: what you set this frame, else what the material carries; "" for a slot you cleared, nil for one nothing has named.
node:getComponent
node:getComponent(name) — a component handle whose fields you can read AND assign at runtime (applies live during play), or nil if absent. Components: RigidBody (friction, restitution, gravity, kinematic 1/0 — live Dynamic/Kinematic switch, shape 0/1/2, radius, height, half_x/y/z, lock_x/y/z, lock_rot_x/y/z, two_d — 2D mode), PointLight (intensity, range, r/g/b, and the EMITTER: shape 0 point / 1 sphere / 2 rect / 3 disk / 4 tube, plus width, height, radius, length, thickness, twoSided — a rect light IS a window, so growing one softens the highlight it leaves on everything — plus shadows, which stops this lamp at the walls between it and what it lights instead of shining through them), Camera (fovY radians, active — assign true to switch cameras), ParticleSystem (play_on_start), UiElement (visible, opacity, posX/posY, width/height, radius, border, fillRGBA, textSize, textRGBA, tintRGBA, cell — spritesheet frame), UiSlider (value/min/max — drive a health bar), UiLayer (enabled, z, designHeight, worldSpace), PostProcess (enabled, bloom, bloomThreshold, bloomIntensity, vignette, vignetteStrength, vignetteRadius, aoStrength, aoRadius, posterizeBands, posterizeDither, tonemap, and the lens: dofFocus, dofRange, dofNearRange, dofBlur, dofBlades, dofBladeAngle, dofHighlight, dofSamples, plus the shutter: motionBlur, motionSamples — a cutscene pushing a vignette, pulling a rack focus, or opening the shutter for a slow-motion beat), LightProbes (enabled, intensity, leak, normalBias — the baked bounce's live knobs; the bake-time ones are not here because a script cannot bake), ReflectionProbe (enabled, intensity, fade — what a room reflects when what it is reflecting is off screen; the box is the node's own shape, and moving or resizing it re-captures). e.g. node:getcomponent("RigidBody").friction = 0.02 for ice.
local rb = node:getComponent("RigidBody")
if rb then rb.friction = on_ice and 0.02 or 0.6 end
rb.friction
Grip, as a coefficient: a ramp holds while tan(its angle) <= friction. 0 is ice, 1 holds exactly 45 degrees, above 1 is grippier still.
rb.gravity
Gravity pull on this body (1/0; assign true/false).
rb.half_x
Box half-extent X.
rb.half_y
Box half-extent Y.
rb.half_z
Box half-extent Z.
rb.height
Capsule total height.
rb.kinematic
Transform-driven mode (1/0; assign true/false, live): never falls or gets pushed, but PUSHES dynamic bodies — platforms, elevators, grabbed objects. (Static mode is the Inspector dropdown — a baked collider, nothing to toggle here.)
rb.lock_rot_x
Freeze rotation about X (1/0).
rb.lock_rot_y
Freeze rotation about Y (1/0).
rb.lock_rot_z
Freeze rotation about Z (1/0).
rb.lock_x
Freeze world X translation (1/0).
rb.lock_y
Freeze world Y translation (1/0).
rb.lock_z
Freeze world Z translation (1/0).
rb.radius
Sphere/capsule radius.
rb.restitution
Bounciness 0..1 (0 = no bounce).
rb.shape
Body shape: 0 = sphere, 1 = capsule, 2 = box.
rb.slopeLimit
Steepest standable surface, in degrees (default 60). Past it nothing grounds the body and no grip holds it.
rb.two_d
2D (1/0): keep the body in the XY plane — it keeps its depth, never drifts out of the layer, and still spins the one way a flat object spins. Collides with the same world a 3D body does.
animation — node:animator
anim:clips
anim:clips() — every playable state name, as a list.
anim:crossfade
anim:crossfade("Idle", 0.3 [, layer]) — transition with an explicit fade time (seconds).
anim:current
anim:current([layer]) — alias of anim:state: the state currently showing (topmost active layer). Nil when idle.
anim:duration
anim:duration("Punch") — the clip's AUTHORED length in seconds (nil if there's no such state). Reads the asset, not playback, so it works in start().
anim:events
anim:events("Punch") — the clip's authored events as { {t = seconds, func = "onHitboxStart"}, … }, ascending by t; nil if there's no such state, an empty list if it has none. Reads the asset, so you can bake integer frame data at load: frame = math.floor(e.t / anim:duration(c) * totalFrames + 0.5). Prefer this to letting events DRIVE gameplay — they fire off float playback time, quantise to sample_fps, and are deliberately not re-fired on a prediction replay.
anim:finished
anim:finished([layer]) — true when a non-looped state reached its end this frame (or stays true while holding the last frame).
anim:isPlaying
anim:isPlaying([state]) — is that state playing on any layer (or anything at all, with no argument)?
anim:layers
anim:layers() — every layer name, base first, as a list.
anim:play
anim:play("Run" [, fade [, layer]]) — transition to a state. The controller supplies the crossfade (default fade, per-arrow overrides, and a state's ⇥ fade-in override which beats everything — 0 = instant); pass fade to override the first two. Safe to call every frame — re-playing the current state is a no-op.
anim:restart
anim:restart("Attack" [, fade [, layer]]) — like play, but re-enters even if that state is already playing (re-trigger a one-shot).
anim:seek
anim:seek(t [, layer]) — jump the current state's playhead to t seconds.
anim:setLayerWeight
anim:setLayerWeight("Attack", 0.5) — blend a layer over the ones below (0 = off, 1 = full override).
anim:setSpeed
anim:setSpeed(2) — global playback speed multiplier for this node's animator.
anim:state
anim:state([layer]) — the state currently showing (topmost active layer), or that layer's state. Nil when idle.
anim:stop
anim:stop([layer [, fade]]) — stop a layer (all layers if omitted). Higher layers release to the layers below; the base returns to its default state.
anim:time
anim:time([layer]) — seconds into the current state.
particles — effects from script
node:particles
node:particles() — the particle handle for this node's Particle System component. Setters: :play/:stop/:restart/:setIntensity/:setBeamEnd. Getters: :isPlaying/:alive/:asset. e.g. on a hit, node:particles():restart() to re-fire a burst.
particles:alive
particles:alive() — live particle count across the effect's tracks (0 when stopped).
particles:asset
particles:asset() — the effect asset key this node's Particle System references, or nil.
particles:isPlaying
particles:isPlaying() — true while an instance is emitting/ageing on this node.
particles:play
particles:play() — start emitting if the effect is idle (spawns a fresh instance). No-op if already playing.
particles:restart
particles:restart() — re-spawn from t=0 (re-fire a one-shot burst, e.g. a muzzle flash on each shot).
particles:setBeamEnd
particles:setBeamEnd(x, y, z) — aim every Beam track's endpoint at a WORLD-space point (the engine converts it to effect-local, so the beam keeps tracking the target as the node moves). Re-call per tick to follow a moving target.
particles:setIntensity
particles:setIntensity(i) — live emission scale (0..~2): multiplies rates/burst counts and shades particle size. Drive an engine plume off the throttle without touching the asset.
particles:stop
particles:stop() — stop + despawn the effect; its live particles vanish.
spawnEffect
spawnEffect(key, x, y, z) — fire a one-shot particle effect at a world point, no node needed. It plays once and despawns itself. Local to this machine: in a session, spawn it from state that replicates (a synced counter every peer watches), or the server's sparks stay on the server. e.g. local h = raycast(...); if h then spawnEffect("vfx/Impact", h.x, h.y, h.z) end.
audio — sounds & the mixer
audio
Sounds and the mixer: audio.play for one-shots, audio.track for a mixer bus, node:sound() for a node's Audio Source.
audio.play
audio.play(clip [, node | x, y, z] [, opts]) — play a clip with no setup: audio.play("audio/ding.ogg") is flat 2D; pass x,y,z for a world point; pass a node to follow it. Local to this machine: in a session, play it from state that replicates, or a sound the server's code plays is heard by nobody. opts: {volume, pitch, pan, mode="Spatial|Distance|Flat", falloff="Inverse|Linear|Exponential", minDistance, maxDistance, track, endBehavior="Stop|Destroy|Loop", loop=true}. Returns a sound handle: :stop/:pause/:resume/:setVolume/:setPitch/:setPan/:setTrack/:setPosition/:seek/:isPlaying/:position. e.g. audio.play("audio/hit.ogg", h.x, h.y, h.z, { maxDistance = 35, track = "SFX" })
audio.play("audio/footstep", node, { track = "SFX", volume = 0.6, minDistance = 4 })
audio.stopAll
audio.stopAll() — stop every playing sound (sources and one-shots), with a click-free fade.
audio.track
audio.track(name) — a live mixer-track handle ("Master" or a track from the Mixer tab): :setVolume(db), :setPan(-1..1), :setMuted(bool), :setSoloed(bool). Changes affect the running session only and revert on Stop. e.g. audio.track("Music"):setVolume(-12) to duck music.
sound:isPlaying
Still audible (false once finished)?
sound:pause
Freeze playback.
sound:position
Playhead in seconds.
sound:resume
Continue a paused sound.
sound:seek
Jump the playhead to a time in seconds.
sound:setPan
Stereo pan −1..1 (non-spatial sounds).
sound:setPitch
Playback-rate pitch (0.5 = octave down, 2 = octave up).
sound:setPosition
Move the emitter (stops following a node).
sound:setTrack
Re-route through a mixer track ("Master" or a track name).
sound:setVolume
Linear volume (1 = as authored).
sound:stop
Fade the sound out and end it.
source:isPlaying
Is the source audible right now?
source:pause
Freeze playback (resume continues from here).
source:play
Play the source's clip from the start (restarts if already playing).
source:position
Playhead in seconds.
source:resume
Continue a paused sound.
source:seek
Jump the playhead to a time in seconds.
source:setClip
Swap the clip (project-relative path like "audio/steps.ogg"); restarts playback if playing.
source:stop
Fade the sound out (a few ms — no click).
track:setMuted
Mute / unmute the track.
track:setPan
Stereo pan −1..1.
track:setSoloed
Solo the track (mutes everything else).
track:setVolume
Fader gain in dB (0 = unity, −60 = silent).
assets
assets
Reference files under Assets/ in code: assets.getFile(path), assets.getContents(dir). Read and write your own data files: assets.readText / writeText, assets.readJson / writeJson.
assets.getContents
assets.getContents("models") — an array of every file under that folder (recursive). Build tables of assets with it. Relative to Assets/ and inside it; at most 20 000 files, and it says when it stopped.
assets.getFile
assets.getFile("models/armor.glb") — the asset's path (or nil), to hand to node.model / node.material. Path is relative to Assets/ and stays inside it: an absolute path or .. is nil and one Console line.
assets.readJson
assets.readJson("charts/neon.json") -> value, err — a JSON file decoded straight to a Lua value (json.decode rules: objects are tables, arrays are 1-based lists tagged with json.array, null is nil). nil and a message for a missing, unreadable or malformed file, plus one Console line. The way a rhythm chart, a dialogue tree or a level table gets into a script without being rewritten as Lua. Refuses: everything assets.readText refuses, plus: the bytes are not valid JSON. Each one names the call and the path.
assets.readText
assets.readText("data/intro.txt") -> text, err — a text file's whole contents (UTF-8), or nil and why: missing, not text, over 64 MB, or a path outside the project. Relative to Assets/ and inside it. Works the same from an exported build and in a browser (it reads the bundle). Refuses: the path leaves the project (absolute, or containing ..); no such file; the file is bigger than 64 MB; the bytes are not UTF-8 text. Each one names the call and the path.
assets.writeJson
assets.writeJson("charts/neon.json", value [, { pretty = true }]) -> ok, err — encode a Lua value as JSON (json.encode rules; json.array{} for an empty list) and write it under Assets/. pretty indents it for a person or a git diff. A chart editor built IN the game saves straight into the project, and the file it wrote is one the Asset Browser shows and the export ships. Refuses: everything assets.writeText refuses, plus: the value contains something JSON cannot hold (a function, a cycle).
assets.writeText
assets.writeText("charts/neon.txt", text) -> ok, err — write a file under Assets/, creating the folders on the way and replacing what was there. A path outside the project is refused (false and why). Editor or exported build alike; in a browser the write lands in the page's own storage and survives a reload. Refuses: the path leaves the project (absolute, or containing ..); the path is an existing FOLDER; the folder could not be created; the write itself failed (permissions, a full disk).
debug gizmos
gizmo
Immediate-mode debug drawing (play mode): gizmo.line/ray/sphere/point show for ONE frame in the Scene view (never the Game view; the viewport gizmos toggle hides them). Call every frame you want a shape visible.
gizmo.line
gizmo.line(x1,y1,z1, x2,y2,z2 [, r,g,b]) — a world-space debug line for one frame. Color is 0–1 floats (default green).
gizmo.point
gizmo.point(x,y,z [, size [, r,g,b]]) — a small 3-axis cross marking a spot: hit points, waypoints, spawn locations.
gizmo.ray
gizmo.ray(ox,oy,oz, dx,dy,dz [, len [, r,g,b]]) — a debug ray: origin + direction. With len the direction is normalized and the ray is that long — mirrors raycast(...), perfect for visualizing ground checks / line-of-sight.
gizmo.sphere
gizmo.sphere(x,y,z [, radius [, r,g,b]]) — a wire debug sphere (three rings): trigger zones, blast radii, pickup ranges.
lua stdlib
math.abs
math.abs(x) — absolute value.
math.approach
math.approach(current, target, maxDelta) — move toward target without ever overshooting. Pass rate * dt; this is the correct version of the hand-rolled move-towards that jitters at low frame rates.
-- frame-rate correct, never overshoots
throttle = math.approach(throttle, target, params.rate * dt)
math.approachAngle
math.approachAngle(current, target, maxDelta) — math.approach for headings: turns the short way and never overshoots. Turrets, camera yaw, 'face the player'.
math.clamp
math.clamp(x, lo, hi) — x held inside the range. Reversed bounds are tolerated rather than returning NaN.
hp = math.clamp(hp + heal, 0, 100)
math.cos
math.cos(x) — cosine of x (radians).
math.deg
math.deg(rad) — radians to degrees.
math.deltaAngle
math.deltaAngle(a, b) — the SHORTEST signed turn from a to b, correct across the +/-pi seam (350 degrees to 10 is +20, not -340).
-- the short way round, across the +/-pi seam
local turn = math.deltaAngle(node.yaw, wanted)
node.yaw = math.approachAngle(node.yaw, wanted, params.turn_rate * dt)
math.fbm
math.fbm(x, y, z [, octaves [, seed]]) — seeded fractal noise (default 4 octaves, rotated so features never align to the axes), about -1..1. Terrain-style variation for scripts: scatter decorations, vary spawns, wobble paths.
math.floor
math.floor(x) — round down.
math.inverseLerp
math.inverseLerp(a, b, x) — where x sits between a and b, 0..1. Returns 0 when a == b instead of a NaN that poisons everything downstream.
math.lerp
math.lerp(a, b, t) — linear blend, UNCLAMPED (t outside 0..1 extrapolates, which is useful). Use math.mix for the clamped version.
math.max
math.max(a, b, …) — largest argument.
math.min
math.min(a, b, …) — smallest argument.
math.mix
math.mix(a, b, t) — math.lerp with t clamped to 0..1.
math.noise
math.noise(x, y, z [, seed]) — seeded value noise, one octave, about -1..1, identical on every machine (the same numbers the engine's Rust generators use). Scale the inputs to pick a frequency.
math.pi
The constant π.
math.pingPong
math.pingPong(t, len) — 0 to len and back, forever. Patrols, bobbing, breathing lights.
math.rad
math.rad(deg) — degrees to radians.
math.random
math.random() — random in [0,1); math.random(n) — 1..n.
math.remap
math.remap(x, a, b, c, d) — x from the range a..b onto c..d. The one-liner behind fades, falloffs and gauge needles.
local alpha = math.remap(distance(node, player), 5, 25, 1, 0)
math.round
math.round(x [, step]) — nearest whole number, or nearest multiple of step: math.round(x, 0.25) snaps to quarters for grid placement.
math.saturate
math.saturate(x) — clamp to 0..1, the most-written clamp of all.
math.sign
math.sign(x) — -1, 0 or 1. Exactly 0 for 0 (not 1, which is what math.abs tricks give you).
math.sin
math.sin(x) — sine of x (radians).
math.smoothstep
math.smoothstep(a, b, x) — 0..1 with eased ends, for anything that shouldn't start and stop abruptly.
math.sqrt
math.sqrt(x) — square root.
math.wrapAngle
math.wrapAngle(a) — an angle folded into (-pi, pi].
rng
rng(seed) — a DETERMINISTIC random stream: same seed, same sequence, every machine. r:next() in [0,1), r:range(a,b), r:int(a,b) inclusive, r:pick(list). Use for gameplay that must reproduce (loot, procgen scatter, server replays); math.random stays for throwaway rolls.
rng:int
Uniform integer in [a, b] inclusive.
rng:next
Uniform in [0, 1).
rng:pick
A uniform element of list (nil if empty).
rng:range
Uniform in [a, b).
string.format
string.format(fmt, …) — printf-style formatting.
table.copy
table.copy(t) — a shallow copy (keys and values).
table.count
table.count(t [, fn]) — how many entries (works on KEYED tables, which #t cannot), or how many satisfy the predicate.
table.extend
table.extend(dst, src) — append src's items onto dst in place, and return dst.
table.filter
table.filter(list, fn) — a new list of the items where fn(value, i) is true.
local ready = table.filter(ships, function(s) return s.fuel > 0 end)
table.find
table.find(list, fn) -> value, index — the first item satisfying the PREDICATE (nil, nil if none). table.find(ships, function(s) return s.docked end).
local docked, i = table.find(ships, function(s) return s.docked end)
table.indexOf
table.indexOf(list, value) — the index of a value by plain equality, or nil.
table.keys
table.keys(t) — the keys as a SORTED list. Sorted because raw pairs order is hash order, which a replay can't reproduce.
table.map
table.map(list, fn) — a new list of fn(value, i). Never mutates the input.
local names = table.map(crew, function(m) return m.name end)
table.reverse
table.reverse(list) — a new list, back to front.
table.sum
table.sum(list [, fn]) — add the numbers, or add fn(value, i) over them: table.sum(tanks, function(t) return t.fuel end).