Veduta

Lua games

A script game is written in Lua 5.4. veduta.json names its main script, and the tool runs it itself: no Go code, no build, the same commands (build checks the scripts, test, simulate, render, bench, fuzz, run) and the same MCP tools.

{ "veduta": "project/1", "name": "mygame", "engine": "v2.0.0", "script": "main.lua" }

Every .lua file of the project is read when a run starts (the directories out, bin, the cooked assets and hidden ones are skipped). The main script runs once per run, before the scene is loaded; it fills the game and kinds tables. A run is deterministic: the same seed and the same inputs give the same trace on every machine.

Playing it

veduta sim (Windows) plays the game in the simulator: the console's panel at a whole scale, in its 16-bit colors, with the keyboard pressing the buttons (arrows or W A S D, Space or Z for A, X or Shift for B, Enter or Tab for Select, Escape or Backspace for Cancel; Ctrl+Q leaves) or any pad. Three keys work the player itself:

KeyDoes
F1shows update, render and frame milliseconds against the tick's budget, and triangles against the console's 1200
F5restarts the game and records the buttons; F5 again saves them as tests/scenarios/recorded-<time>.vscenario, a scenario to add expectations to
F9reads the scripts and assets again and restarts the game from the start

Editing while it plays

The simulator watches the scripts and the asset sources. When one is saved it reads everything again (a changed scene, texture or material is compiled on the spot) and restarts in place: in the scene the game was in, or in its world around the cell the player stands in, with the same seed. So a level is laid out with the scene file open next to the simulator, and a hud is written in game.draw while it shows: every save shows the result a second later, without playing back to it. The run starts again at tick 0 there; game.init runs again, so a game whose init loads its title scene comes back to the title.

veduta sim --scene level3 (or --world land --at 4,-2, and --seed N) opens the simulator in that scene rather than the project's default; the debugger's "scene" does the same in VS Code (the snippet "Veduta: Play a scene"). F5 records from there, and the scenario it writes names that scene.

An error stops the run but not the simulator: the last frame stays, the error (a script's file, line and traceback; an asset that does not compile; a scene that does not load) is written over it, and the next save that fixes it reloads. F9 does the same from the start.

Debugging

veduta dap is a debug adapter (the Debug Adapter Protocol, on stdin and stdout), which VS Code's Veduta extension starts on F5. It plays the game ("mode": "play", the simulator on Windows) or runs a scenario headless ("mode": "scenario", "scenario": "collect") with a debugger on the scripts: breakpoints, pause, stepping over, into and out of calls (into a module require runs too), the call stack, each call's locals and upvalues and the globals, tables and entities opened field by field, and a name with its fields (e.state.score) evaluated on hover or as a watch. "stopOnEntry": true stops before the first statement. Ctrl+F5 runs without it. While the game is stopped the simulator's window does not redraw. A run under the debugger gives the same trace as one without.

Editing

veduta init sets an editor up, and veduta upgrade brings it to the tool's version: .veduta/lua/veduta.d.lua describes this API for the Lua Language Server (VS Code's sumneko.lua, which .vscode/extensions.json recommends; .luarc.json points it there and turns off io, os, debug and package), and .veduta/schema/ holds a JSON Schema of every source format, which .vscode/settings.json maps to veduta.json, *.vscene, *.vscenario and the other asset files. The editor then completes the API and the fields of every file, shows their descriptions, and marks a misspelt function, a button that does not exist or a field the format does not have. upgrade sets only its own keys in .vscode/settings.json, .vscode/extensions.json and .luarc.json, and leaves one with comments in it as it is.

The game

function game.init()        -- once, after the first scene is loaded
end

function game.update()      -- once per tick (20 per second), before the entities
end

function game.draw()        -- once per rendered frame, after the scene: the hud
end

All three are optional. A Lua error in any of them, or in a kind, stops the run with the script's file, line and traceback.

FunctionMeaning
game.pause([on])stops the world (true or nothing) or starts it again (false): kinds, bodies, particles, on_touch and the timers, tweens and tasks of the game wait; game.update, screens, dialogs and toasts go on, so the game can resume
game.paused()whether the world is paused

Kinds

An entity whose kind names an entry of kinds gets its behaviour from it:

kinds.coin = {
  init = function(e)         -- when the entity is loaded or spawned
    e.state.value = 1
  end,
  update = function(e)       -- once per tick, in entity id order, after game.update
    e:set_rotation(0, engine.tick * 4.5, 0)
  end,
}

static, camera and light are built-in kinds. A scene that names a kind no script defines fails to load.

Two more callbacks, both optional:

kinds.coin = {
  on_touch = function(e, other)   -- e and other started to overlap (end of the tick)
    if other:has_tag("hero") then e:despawn() end
  end,
  on_despawn = function(e)        -- e:despawn() is removing it
    particles.burst{x = e.x, y = e.y, color = "#ffd84a"}
  end,
}

on_touch runs once when two entities start to overlap, for each of them whose kind has it, as the trace's collision event; on_despawn runs inside e:despawn(), before the entity goes.

Entities

An entity is a value with fields and methods. The same entity is always the same value, so a == b compares entities.

FieldMeaning
id, name, kindread only
alivefalse once despawned (read only)
x, y, zposition, relative to the parent
visibledrawn or not
model, materialasset names, or nil
layerfirst key of the draw order
framethe frame shown when the texture is a sheet (a grid or frames, or a material grid): an integer from 0, left to right then top to bottom, wrapping around the grid's frames. Animate with e.frame = engine.tick // 4 % 6, or with a clip
animthe clip of the entity's texture it plays, or nil: setting another clip starts it, setting the one playing changes nothing, nil stops (the frame stays). While a clip plays, the engine sets frame at the end of every tick; a clip that does not loop ends on its last frame or goes on with its next
anim_donetrue once a clip that does not loop has shown its last frame and has no next (read only)
parentthe parent entity, or nil; set it to an entity (or its name) or nil. The position, rotation and scale are relative to the parent, so an entity given a parent moves with it from then on
hitbox{{min x, y, z}, {max x, y, z}} in the entity's own space, or nil: replaces the model's bounds for collisions, as a scene file's hitbox
colora tint multiplying every part the entity draws ("#40a0ff", 0x40a0ff; nil for none); read back as an integer 0xrrggbb. "#rrggbbaa" sets alpha too
alphafrom 0 (invisible) to 1 (default): below 1 the entity is blended over what is behind it
bodya table the engine moves each tick after the kinds, or nil: vx, vy (meters a second), gravity (pulls vy down, meters a second²), max_fall, solid (the tag that stops it, default "solid"; false for none), oneway (default "oneway"). It moves as e:move_and_slide does and sets on_ground, on_ceiling, on_wall (−1, 0, 1); a side it hits zeroes that velocity. Jump with e.body.vy = 12
statea table of your own values; the trace records it as state.<key>, so scenarios can check it (state.score). Entities of a Lua kind start with an empty one.

Setting any other field is an error: keep your own values in state.

MethodMeaning
e:position()x, y, z
e:set_position(x, y, z)
e:move(dx, dy, dz)adds to the position
e:world_position()x, y, z in the world
e:rotation()Euler angles in degrees, x, y, z
e:set_rotation(x, y, z)degrees
e:scale(), e:set_scale(x, y, z)
e:has_tag(t), e:add_tag(t), e:remove_tag(t), e:tags()
e:overlapping([tag])the live entities whose bounds overlap this one's (last tick's), in id order, only those with tag when given
e:bounds()min x, y, z, max x, y, z, or nil for an entity without bounds
e:children()the live entities whose parent is this one, in id order
e:play(clip)starts the clip from its first frame, even when it is playing
e:clips()the clips the entity can play (its textures'), sorted
e:despawn()removed at the end of the tick, with its children; its kind's on_despawn runs first
e:move_and_slide(dx, dy [, solid [, oneway]])moves by (dx, dy) in the x, y plane, stopped by entities tagged solid (default "solid") and the map's cells tagged so, sliding along them; entities and cells tagged oneway (default "oneway") stop it only falling onto their top. Returns whether it was stopped along x, along y
e:move_toward(x, y, speed [, solid]), e:move_toward(other, speed [, solid])one tick's step (speed meters a second) toward a point or an entity, sliding against solid when given; true once there
e:distance_to(x, y), e:distance_to(other)the distance in the x, y plane
e:face(dx)turns a sprite to the side dx points to (negative: left), mirroring its x scale; 0 keeps it
e:flash([color [, seconds]])paints the entity with a color (default white) for a while (default 0.1 s): a hit
e:cooldown(name, seconds)true, and starts the wait, when the entity's cooldown name is over (or never started); false while it runs: if input.pressed("a") and e:cooldown("shot", 0.3) then … end

scene

FunctionMeaning
scene.name()the scene's name
scene.find(name)the entity, or nil
scene.tagged(tag)a list of entities, in id order
scene.entities()every live entity, in id order
scene.spawn{...}adds an entity and returns it; fields kind (default static), name, model, material, position, rotation (degrees), scale (each {x, y, z}), tags (a list), visible, layer, frame, anim, parent (an entity or a name), hitbox ({{min}, {max}}), state (a table merged into the kind's)
scene.spawn_prefab(name, x, y, z [, rotation [, prefix]])adds the entities of assets/prefabs/<name>.vprefab with the min corner of its footprint at (x, y, z), turned by rotation (0, 90, 180 or 270 degrees about +Y) as a world places it, named <prefix>_<entity> (prefix defaults to the prefab's name) and parented as the prefab says. Returns a table listing them in prefab order that also holds each under its name in the prefab (house.door)
scene.load(name [, {fade =, color =}])replaces the scene, as a reset. With fade (seconds) the frame fades to color (default black), the scene changes, and it fades back, the input taken meanwhile. A load cancels the timers, tweens and tasks but those made with keep (and the task that loads), the cooldowns, the camera's follow and shake and the particles
scene.raycast(x, y, dx, dy, distance [, tag])the first entity tagged tag (default "solid") or map cell tagged so that a ray from (x, y) along (dx, dy) meets within distance, in the x, y plane: {x, y, distance, nx, ny, entity} (a cell gives cell = {x, y} instead of entity; nx, ny is the side's normal), or nil
scene.nearest(x, y [, tag [, max]])the live entity (tagged tag) nearest (x, y) in the x, y plane, within max, and its distance, or nil
scene.within(x, y, radius [, tag])the live entities (tagged tag) within radius of (x, y), nearest first

The scene does not exist while the main script runs: call these from game.init on.

input

The console has eight buttons: up, down, left, right, a, b, select (the game's menu) and cancel (back). Home leaves the game and never reaches it.

FunctionMeaning
input.down(button)held
input.pressed(button)went down this tick
input.released(button)went up this tick
input.dpad()x (−1 left, 0, 1 right), y (−1 down, 0, 1 up)
input.repeated(button [, delay [, rate]])true when it goes down, then again every rate seconds (0.1) once held for delay (0.3): a menu's cursor
input.held(button)how long it has been held, in seconds (0 when up)

While a dialog is open or a scene fades, and on the tick a dialog closes, these read as nothing (false, 0): the dialog has the buttons.

camera

FunctionMeaning
camera.get(){position = {x, y, z}, target = {x, y, z}, ortho, fov, size, near, far}
camera.set{...}changes the fields given, the others stay
camera.follow2d(x, y, height)the orthographic camera of a 2D game, looking at (x, y) and showing height units
camera.follow(entity [, {bounds =, smooth =, deadzone =, offset =, snap =}])moves the camera with the entity each tick, in the x, y plane: bounds "map" (or {left, bottom, right, top}) keeps the view inside, smooth (seconds) eases it, deadzone {w, h} lets the entity move that much before it follows, offset {x, y} looks ahead; it starts on the entity unless snap = false. camera.follow(nil) stops
camera.shake(strength [, seconds])shakes the camera up to strength meters, fading over seconds (0.3); undone before the next tick, so camera.get never sees it
camera.to_screen(x, y [, z])the pixel of the frame where a point of the world is (the project's resolution outside game.draw), and whether it is on the frame: a health bar over an enemy

world

FunctionMeaning
world.load(name, cx, cz)replaces the scene with a world streamed around cell (cx, cz)
world.name()the loaded world, or nil
world.focus(x, y, z)where the chunks follow; call it every tick
world.height(x, z)the ground's height
world.water(x, z)the water level there, or nil

map

The scene's tile map (docs/map.md): a grid of cells painted with terrains, in layers, and objects. Cell (x, y) counts columns right and rows down from the top-left cell, from 0. A layer argument is a layer's name.

FunctionMeaning
map.name()the scene's map, or nil
map.load(name)replaces the scene's map with a map as its file describes it (nil removes it); the entities stay
map.size()columns, rows
map.tile()meters per cell
map.layers()the layers' names, bottom first
map.cell(x, y)the cell holding a point of the world (it may be off the map)
map.center(x, y)the point of the world at the center of cell (x, y)
map.inside(x, y)whether the cell is on the map
map.get(x, y [, layer])the terrain painting the cell (default the first layer), or nil for an empty cell or one off the map
map.set(x, y, terrain [, layer])paints the cell (default the first layer); nil empties it. Drawn at once, borders included; a map_set event in the trace
map.has(x, y, tag [, layer])whether a terrain with the tag paints the cell, on any layer unless one is named
map.tags(x, y [, layer])the tags of the terrains painting the cell, on every layer unless one is named, sorted
map.objects([tag])the map's objects in file order (only those with the tag): tables {name, x, y, w, h, tags, props}, and prefab when it has one
map.object(name)the object, or nil
map.objects_at(x, y)the objects covering the cell, in file order
map.object_of(entity)the object whose prefab spawned the entity, or nil
map.move(entity, dx, dy [, tag [, w [, h]]])moves the entity by (dx, dy) meters, stopped by the cells tagged tag (default "solid", on any layer) and by the map's sides, sliding along walls. Its box is w × h meters around its position (default its bounds; h defaults to w). Returns whether it was stopped along x, along y
map.find(what [, layer])the cells {x, y} painted with the terrain what or with a terrain tagged what, row by row, on any layer unless one is named
map.fill(x, y, w, h, terrain [, layer])paints a rectangle of cells, as map.set does each
map.path(x0, y0, x1, y1 [, tag])the shortest way between two cells through the sides of cells not tagged tag (default "solid"): the cells {x, y} after the first, or nil when there is none (or none within 16384 cells searched). The same search always gives the same way
map.sight(x0, y0, x1, y1 [, tag])whether the straight line of cells between two cells crosses no cell tagged tag (default "solid"); the first cell does not count
map.z(y [, layer])the z at which to draw something standing at world y among the cells of a ysort layer (default the first one): e.z = map.z(e.y - 0.5) for a sprite 1 meter high puts its feet in order with trees and walls
map.data(x, y, key)the value the game keeps in the cell under key, or nil
map.set_data(x, y, key, value)keeps a string, a number or a boolean in the cell (nil forgets it): a crop's age, a chest's state. A map_data event in the trace
map.changes()what differs from the map's file: {map, cells = {{layer, x, y, terrain}, …}, data = {{x, y, key, value}, …}}, a table save.write takes
map.apply(changes)paints the cells and keeps the values of map.changes() back, after loading; an error when they are another map's

A scene without a map raises an error from every function but map.name and map.load.

hud

Only inside game.draw, except hud.text_width and hud.wrap, which measure. Coordinates are pixels from the top left of the frame.

FunctionMeaning
hud.text(x, y, text [, color [, scale]]), hud.text(x, y, text, {color =, scale =, align =, shadow =})the built-in 8×8 font; returns the width drawn. align "center" centers each line on x, "right" ends it at x; shadow draws the text one pixel down and right in that color first. The font has ASCII, Latin-1 (à è é ì ò ù ç ñ ä ö ü ß …) and Windows-1252's extra characters (€ ‘ ’ “ ” – — …); others draw as ?. \n starts a new line
hud.text_width(text [, scale])the width in pixels hud.text would draw (count characters, not bytes: #text counts the two bytes of è); usable anywhere
hud.wrap(text, width [, scale])the text broken into lines at most width pixels wide, at spaces (a longer word is cut), and the number of lines; usable anywhere
hud.rect(x, y, w, h [, color])a filled rectangle
hud.outline(x, y, w, h [, color [, thickness]])a rectangle's border
hud.line(x1, y1, x2, y2 [, color [, width]])a line of whole pixels
hud.circle(x, y, r [, color [, fill]])a circle's outline, or a disc when fill
hud.bar(x, y, w, h, value, max [, options])a bar filled value / max: color (green), back (the empty part, false for none), border, low (the color at low_at = 0.25 or less), vertical (fills up)
hud.text_box(x, y, w, h, text [, options])the text wrapped to w and cut to the lines that fit h: color, scale, align, valign ("top", "middle", "bottom"), spacing (extra pixels between lines), shadow. Returns the lines drawn and the lines the text has
hud.image(texture, x, y [, options])a texture asset, or a part of it, with its texels sharp. options: src = {x, y, w, h} the part in texels (an icon of a sheet; default all of it), w, h the size drawn in pixels (default the part's), color multiplying the texels, flip_x, flip_y
hud.panel(texture, x, y, w, h, border [, options])a nine-slice panel of any size: the corners, border texels wide (one number, or {left, top, right, bottom}), keep their size; edges and middle stretch. options: src, color
hud.image_size(texture)the texture's width and height in texels

A color is "#rrggbb", "#rrggbbaa" or an integer 0xrrggbb; the default is white. A texture drawn in the hud is a texture asset like any other (assets/textures/<name>.vtex, often a PNG image layer): an icon sheet is one texture and src picks each icon.

engine

engine.tick (0 in game.init), engine.dt (seconds per tick), engine.width, engine.height (the frame: the project resolution, or in game.draw the frame being drawn), engine.headless, the project's engine.name and engine.title, and engine.api, the API level of the runtime (1 in v2.0.0). A game that needs a later level says so with "api" in veduta.json, and an older console refuses it with a message.

mesh and volume

Models built while the game runs: the terrain of a block world, a shape that changes. The loops run in the engine, not in Lua, so a whole chunk of blocks is one call.

FunctionMeaning
mesh.new()an empty mesh
m:part([material])what follows is drawn with material (a material's name); without one, with the entity's
m:quad(x1, y1, z1, x2, y2, z2, x3, y3, z3, x4, y4, z4)a quad, its corners counter-clockwise seen from the side that shows; UVs (0, 0), (1, 0), (1, 1), (0, 1)
m:triangle(x1, y1, z1, x2, y2, z2, x3, y3, z3)a triangle, likewise
m:box(x, y, z, w, h, d [, faces])the box from (x, y, z), w × h × d; faces names the faces to add, run together ("+y-y"), default all six
m:triangles()how many triangles the mesh has
mesh.set(name, m)makes m the model name, which entities name as model like an asset: the name contains : ("game:chunk_0_0") and does not start with world:. It copies the mesh: change m and set it again to change the model. Models set here outlive scene.load
mesh.remove(name)forgets the model; entities naming it draw nothing
volume.new(x, y, z)a grid of blocks, each a block id from 0 (empty) to 255; at most 4 194 304 blocks
v:get(x, y, z), v:set(x, y, z, id)one block; cells count from 0
v:fill(x1, y1, z1, x2, y2, z2, id)every block of the box between two cells
v:size()x, y, z
mesh.voxels(v, materials [, size])a mesh of the faces between a block and an empty cell or the edge: one part per block id, drawn with materials[id] (a table of id → material name), each block size units (default 1), cell (0, 0, 0) at the origin
local world = volume.new(16, 8, 16)
world:fill(0, 0, 0, 15, 2, 15, 1)       -- three layers of stone
world:fill(0, 3, 0, 15, 3, 15, 2)       -- grass on top
mesh.set("game:chunk", mesh.voxels(world, {[1] = "stone", [2] = "grass"}))
scene.spawn{name = "chunk", model = "game:chunk"}

save

Saves outlive a run: progress, settings, high scores. A save is a table of numbers, strings, booleans and tables of them, stored under a name (a valid asset name: slot1, settings). Integers and floats come back as they went in, and tables in the order of their keys, sorted.

FunctionMeaning
save.write(name, table)stores the table as the save name, replacing any; true, or nil and a message when the storage fails. A value a save cannot hold (a function, an entity, a key that is neither a string nor part of a list, a table that holds itself) or a save over 1 MiB of JSON is an error
save.read(name)the save as a new table, or nil when there is none (and a message when it cannot be read)
save.remove(name)deletes the save; true, or nil and a message
save.list()the names of the saves, sorted
save.best(key [, value [, "low"]])a record kept in the save best: with a value, stores it when it beats the record (higher, or lower with "low": a time) and returns the record and whether it is new; without, the record or nil

Where saves live depends on the run. The simulator and the console keep them in files, one per save: the console on its card (saves/<game>/), the simulator in out/saves of the project, or wherever VEDUTA_SAVE_DIR says. Tests never touch those: a run starts with the saves its scenario lists ("saves" in the scenario topic) and keeps its writes in memory, recording save_write and save_remove events a scenario can count.

function game.init()
  local data = save.read("slot1")
  if data then
    gold, level = data.gold, data.level
  end
end

local function on_checkpoint()
  local ok, err = save.write("slot1", {gold = gold, level = level, party = {"mira", "tobi"}})
  if not ok then
    message = "COULD NOT SAVE"   -- the card is full or missing; the game goes on
    print(err)
  end
end

Time: timer, tween, task

Timers, tweens and tasks count ticks, so they replay exactly. Each belongs to the screen that made it (it waits while another screen is pushed over, and is cancelled when that screen leaves) and to an owner entity when it has one (cancelled when the entity is despawned). game.pause stops those of the game. A scene or world load cancels all but those made with keep. They run after the kinds, in the order they were made.

FunctionMeaning
timer.after(seconds, fn [, owner])calls fn once after that long; returns a handle (h:cancel(), h:done()). owner is an entity or {owner = entity, keep = true}
timer.every(seconds, fn [, owner])calls fn every seconds until it returns false or is cancelled
timer.cancel(handle)stops a timer, a tween or a task
timer.cooldown(key, seconds)true, and starts the wait, when the cooldown key is over; false while it runs (entities have e:cooldown)
tween(target, values, seconds [, easing [, done]])moves the numeric fields of an entity or a table to values (tween(e, {x = 5, alpha = 0}, 0.5, "out_quad")) and calls done(target) at the end; a newer tween of the same field takes over. Easings: linear, and in_, out_, in_out_ + quad, cubic, quart, sine, expo, circ, back, elastic, bounce
tween.cancel(target or handle)stops the tweens of a target, or one
task.start(fn [, owner])runs fn as a task now, until it waits; returns its handle. A cutscene, an enemy's pattern
task.cancel(handle), task.current()stops a task; the task running now, or nil
wait([seconds])inside a task: goes on after that long (the next tick without)
wait_for(button)inside a task: goes on when the button is pressed
wait_until(fn)inside a task: goes on when fn() returns true, checked every tick
task.start(function()
  ui.dialog("Mira", "The bridge is out.")
  tween(scene.find("boat"), {x = 12}, 2, "in_out_sine")
  wait(2)
  if ui.dialog("Mira", "Get in?", {choices = {"Yes", "No"}}) == 1 then scene.load("river", {fade = 0.4}) end
end)

Flow: screen, signal

A game made of screens gives each one a table of callbacks and moves between them. The screens on the stack are drawn bottom first, after game.draw; only the top one updates. game.update and the kinds run only while there is at most one screen: a screen pushed over the game (a pause menu, an inventory) stops it until it is popped.

FunctionMeaning
screen.add(name, {enter =, update =, draw =, leave =, resume =})defines a screen; every callback is optional. enter(...) gets go's and push's extra arguments, resume(...) pop's
screen.go(name, ...)leaves every screen and enters this one
screen.push(name, ...)enters this one over the current one
screen.pop(...)leaves the top screen, back to the one under it
screen.current(), screen.depth()the top screen's name (nil for none), how many there are
signal.on(name, fn [, owner])calls fn(...) on every signal.emit(name, ...), in the order they were added, until signal.off(handle) or the owner entity is despawned
signal.emit(name, ...), signal.off(handle)
screen.add("title", {update = function() if input.pressed("a") then screen.go("play") end end,
                     draw = function() hud.text(160, 100, "PRESS A", {align = "center"}) end})
screen.add("play", {enter = function() scene.load("level1") end,
                    update = function() if input.pressed("select") then screen.push("pause") end end})
screen.add("pause", {update = function()
                       local i = pause_menu:update()
                       if i == 1 or input.pressed("select") then screen.pop() end
                     end,
                     draw = function() pause_menu:draw() end})
function game.init() pause_menu = ui.menu{"Resume", "Quit"}; screen.go("title") end

ui

Menus, dialogs and toasts drawn in the look of ui.style (back, border, text, accent, dim colors, or panel, a texture drawn as a nine-slice with panel_border).

FunctionMeaning
ui.menu{items… [, x =, y =, w =, title =, index =, align =, scale =, wrap =, on_select =, on_cancel =, on_change =]}a menu: each item a string or {text =, disabled =, on_select =}. m:update() moves the cursor (input.repeated), skips disabled items and returns the index and the item chosen with A this tick, or nil (and "cancel" on B or Cancel); m:draw() draws it (centered unless x, y); m.index, m:select(i), m:size()
ui.dialog(speaker, text [, {choices =, speed =, on_done =}])a dialog box at the bottom of the frame: the text typed at speed characters a second (40; 0 at once), in pages of three lines with an arrow, A or B to finish a page or go on, then choices in a menu. Dialogs queue. Inside a task it waits and returns the choice (nil without choices); outside, on_done(choice) gets it. While open, the game's input reads as nothing
ui.toast(text [, {sec =, color =}])a short message at the top, for 2 seconds (four at most)
ui.busy()whether a dialog is open or a scene is fading
ui.box(x, y, w, h)a box in the style, for your own windows

particles

Small squares that fly, fall and fade, drawn over the scene and under the hud, 512 at most (a burst past it makes fewer). They move after the kinds and wait while the game is paused.

FunctionMeaning
particles.burst{x =, y = [, z =, count =, color =, color_end =, speed =, angle =, spread =, life =, gravity =, drag =, size =, size_end =, fade =]}count (8) particles at (x, y, z) going out at speed (meters a second, a number or {min, max}: 2 to 4) in directions spread degrees wide (360) around angle (90, up), living life seconds ({0.4, 0.8}), pulled down by gravity, slowed by drag, size pixels (2) growing to size_end, from color to color_end, fading out unless fade = false
particles.clear(), particles.count()

settings, lang and T

settings is a table that saves itself: settings.volume = 3 writes the save settings at once, and the next run reads it back. settings.defaults{volume = 5} gives the values of keys never set; settings.reset() forgets them all, settings.all() is a copy of every value.

Texts in several languages live in lang/<code>.lua beside the main script, each returning a table (nested tables give dotted keys):

-- lang/it.lua
return {menu = {play = "Gioca"}, hello = "Ciao, {name}!"}
FunctionMeaning
T(key [, vars])the text in the current language, else in English, else the key itself; {name} takes vars.name
lang.list()the languages, sorted
lang.get()the current one: settings.lang when there is such a file, else en, else the first
lang.set(code)changes it, and remembers it in settings.lang

trace, invariant, require

FunctionMeaning
trace(name [, fields])adds an event to the tick's trace; scenarios count them
invariant(name, predicate)a check scenarios and veduta.json can list by name; the predicate returns true while it holds
require(module)runs module.lua (dots are directories, relative to the main script) once and returns what it returned

The standard library

string, table, math and utf8 behave as in Lua 5.4, with the additions below.

FunctionMeaning
math.clamp(x, lo, hi), math.lerp(a, b, t), math.sign(x)
math.round(x [, step])to the nearest integer (halves up), or multiple of step
math.approach(x, target, step)x moved toward target by at most step
math.distance(x1, y1, x2, y2), math.angle(x1, y1, x2, y2)the distance; the angle in degrees from +x, counter-clockwise
random.int(a, b), random.float([a, b]), random.chance(p)from math.random, so deterministic
random.choice(list), random.shuffle(list)an element (nil for an empty list); the list shuffled in place
random.weighted(t)a key of {gold = 3, rock = 1} or an item of {{"gold", 3}, {"rock", 1}}, by weight
table.find(list, value), table.remove_value(list, value)the first index of value, or nil; removes it, true if it was there
table.copy(t [, deep])a copy, of nested tables too when deep

math.random draws from the run's seeded generator, so it is deterministic. print writes to the tool's error output. coroutine works as in Lua 5.4, and a coroutine may even yield from inside a function the engine calls back (a table.sort comparison). Differences from Lua 5.4: pairs visits keys in the order they were first set; there is no goto, no io, os, debug or load, and no string.pack, string.unpack, string.packsize or string.dump. A callback that runs for 20 million steps without returning is stopped as an endless loop.

Full example

main.lua of a game where the D-pad moves a hero that collects coins:

local SPEED = 4
local score = 0

function game.init()
  invariant("score_non_negative", function() return score >= 0 end)
end

function game.draw()
  hud.text(4, 4, "SCORE " .. score)
end

kinds.hero = {
  update = function(e)
    local dx, dy = input.dpad()
    e:move(dx * SPEED * engine.dt, dy * SPEED * engine.dt, 0)
    for _, coin in ipairs(e:overlapping("coin")) do
      score = score + 1
      trace("coin_collected", {coin = coin.name, score = score})
      coin:despawn()
    end
    e.state.score = score
  end,
}