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:
| Key | Does |
|---|---|
| F1 | shows update, render and frame milliseconds against the tick's budget, and triangles against the console's 1200 |
| F5 | restarts the game and records the buttons; F5 again saves them as tests/scenarios/recorded-<time>.vscenario, a scenario to add expectations to |
| F9 | reads 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.
| Function | Meaning |
|---|---|
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.
| Field | Meaning |
|---|---|
id, name, kind | read only |
alive | false once despawned (read only) |
x, y, z | position, relative to the parent |
visible | drawn or not |
model, material | asset names, or nil |
layer | first key of the draw order |
frame | the 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 |
anim | the 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_done | true once a clip that does not loop has shown its last frame and has no next (read only) |
parent | the 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 |
color | a tint multiplying every part the entity draws ("#40a0ff", 0x40a0ff; nil for none); read back as an integer 0xrrggbb. "#rrggbbaa" sets alpha too |
alpha | from 0 (invisible) to 1 (default): below 1 the entity is blended over what is behind it |
body | a 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 |
state | a 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.
| Method | Meaning |
|---|---|
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
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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
| Function | Meaning |
|---|---|
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
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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).
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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}!"}
| Function | Meaning |
|---|---|
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
| Function | Meaning |
|---|---|
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.
| Function | Meaning |
|---|---|
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,
}