Game Toolkit
The parts most games write again and again come with the engine: timers and tweens, cutscenes that wait, bodies that fall and slide, screens and a pause, menus and dialogs, camera follow and shake, particles, records and settings. This page shows each at work; the Lua API Reference lists every argument.
Everything here counts ticks and draws from the run's seeded generator, so it replays exactly: a scenario sees the same cutscene, the same bounce and the same particles every time.
Time: timers, tweens, tasks
kinds.bomb = {
init = function(e)
-- blinks every 0.2 s until it goes off; both stop if the bomb is despawned first
timer.every(0.2, function() e.visible = not e.visible end, e)
timer.after(2, function()
camera.shake(0.4)
e:despawn()
end, e)
end,
}
kinds.door = {
update = function(e)
if e.state.open and not e.state.moving then
e.state.moving = true
tween(e, {y = e.y + 2}, 0.6, "out_back", function() trace("door_open") end)
end
end,
}
timer.after(seconds, fn [, owner])calls once,timer.everyuntilfnreturnsfalse. Both return a handle:h:cancel(),h:done().- An owner entity cancels them when it is despawned.
{keep = true}(or{owner = e, keep = true}) survivesscene.load; everything else is cancelled by it. tween(target, values, seconds [, easing [, done]])moves numeric fields of an entity (x,y,z,alpha) or of any table. A newer tween of the same field takes over. Easings:linear, andin_,out_,in_out_+quad,cubic,quart,sine,expo,circ,back,elastic,bounce.timer.cooldown(key, seconds)ande:cooldown(name, seconds)answer "may I do it again yet?": true, and the wait starts, when it is over.
A cutscene
A task is a function that can wait. It reads top to bottom, like the script of the scene:
local function bridge_cutscene()
local hero, boat = scene.find("hero"), scene.find("boat")
ui.dialog("Mira", "The bridge is out.")
ui.dialog("Old man", "Take my boat. And mind the bats.")
tween(hero, {x = boat.x}, 1, "in_out_sine")
wait(1)
hero.parent = boat
tween(boat, {x = boat.x + 12}, 3, "in_out_sine")
wait(2)
scene.load("river", {fade = 0.5})
end
function game.update()
if input.pressed("b") and not ui.busy() then
task.start(bridge_cutscene)
end
end
| Inside a task | Goes on |
|---|---|
wait(seconds) | after that long; wait() on the next tick |
wait_for(button) | when the button is pressed |
wait_until(fn) | when fn() returns true |
ui.dialog(...) | when the player closes it; returns the choice |
task.start(fn [, owner]) runs the task at once, up to its first wait, and returns a
handle; task.cancel(h) stops it. An enemy's attack pattern is a task too:
kinds.turret = {
init = function(e)
task.start(function()
while true do
for _ = 1, 3 do
scene.spawn{kind = "bullet", model = "quad", material = "bullet",
position = {e.x, e.y, 1}, scale = {0.3, 0.3, 1}}
wait(0.15)
end
wait(1.5)
end
end, e) -- ends when the turret is despawned
end,
}
kinds.bullet = {
init = function(e) e.body = {vx = -8} end,
update = function(e)
if e.body.on_wall ~= 0 then e:despawn() end
end,
}
Movement and bodies
e:move_and_slide(dx, dy) moves an entity and stops it at entities and map cells tagged
solid, sliding along them; those tagged oneway stop it only when it falls onto their
top. It returns whether it was stopped along x and along y. A body does the same every
tick from a velocity, with gravity.
Platformer
kinds.player = {
init = function(e)
e.body = {gravity = 40, max_fall = 20}
camera.follow(e, {bounds = "map", smooth = 0.2})
end,
update = function(e)
local dx = input.dpad()
e.body.vx = dx * 6
e:face(dx)
if e.body.on_ground and input.pressed("a") then
e.body.vy = 14
elseif e.body.vy > 0 and not input.down("a") then
e.body.vy = e.body.vy * 0.5 -- let go of A: a shorter jump
end
e.anim = not e.body.on_ground and "jump" or (dx ~= 0 and "run" or "idle")
end,
}
The body moves after the kinds and sets on_ground, on_ceiling and on_wall (−1, 0, 1);
hitting a side zeroes that velocity. vx, vy are meters a second, gravity meters a
second². solid = "wall" or oneway = false change what stops it; e.body = nil removes
it.
Top-down
kinds.player = {
init = function(e)
camera.follow(e, {bounds = "map"})
end,
update = function(e)
local dx, dy = input.dpad()
if dx ~= 0 and dy ~= 0 then dx, dy = dx * 0.7071, dy * 0.7071 end
local step = 5 * engine.dt
e:move_and_slide(dx * step, dy * step)
e:face(dx)
if input.pressed("a") and e:cooldown("attack", 0.4) then
for _, enemy in ipairs(scene.within(e.x, e.y, 1.5, "enemy")) do
enemy:flash()
signal.emit("hit", enemy, 1)
end
end
end,
}
Enemies, touches and queries
kinds.slime = {
init = function(e)
e.state.hp = 3
e:add_tag("enemy")
end,
update = function(e)
local hero = scene.nearest(e.x, e.y, "hero", 6) -- within 6 m, or nil
if hero and not scene.raycast(e.x, e.y, hero.x - e.x, hero.y - e.y, e:distance_to(hero)) then
e:move_toward(hero, 2, "solid")
e:face(hero.x - e.x)
end
end,
on_touch = function(e, other) -- once, when they start to overlap
if other:has_tag("hero") and e:cooldown("bite", 1) then
signal.emit("hurt", other, 1)
end
end,
on_despawn = function(e) -- inside e:despawn()
particles.burst{x = e.x, y = e.y, color = "#60e060", count = 12}
end,
}
function game.init()
signal.on("hit", function(enemy, damage)
enemy.state.hp = enemy.state.hp - damage
if enemy.state.hp <= 0 then enemy:despawn() end
end)
end
| Query | Returns |
|---|---|
scene.nearest(x, y [, tag [, max]]) | the nearest live entity and its distance, or nil |
scene.within(x, y, radius [, tag]) | the entities in the circle, nearest first |
scene.raycast(x, y, dx, dy, distance [, tag]) | the first hit {x, y, distance, nx, ny, entity} (or cell), or nil |
e:distance_to(other), e:distance_to(x, y) | the distance in the x, y plane |
The slime above only chases a hero no solid wall hides. A walker that turns round at the
edge of a platform looks down just ahead of its feet:
kinds.walker = {
init = function(e) e.state.dir = 1 end,
update = function(e)
if not scene.raycast(e.x + e.state.dir * 0.6, e.y, 0, -1, 1) then
e.state.dir = -e.state.dir
end
local hit_wall = e:move_and_slide(e.state.dir * 2 * engine.dt, 0)
if hit_wall then e.state.dir = -e.state.dir end
end,
}
Screens and a pause menu
Screens are the states of the game: title, play, pause, game over. Each is a table of callbacks; only the top one updates, and the game's kinds, bodies and timers wait while another screen is pushed over it.
local pause_menu
screen.add("title", {
update = function()
if input.pressed("a") then screen.go("play", 1) end
end,
draw = function()
hud.text(160, 100, "GEM CAVE", {align = "center", scale = 2, shadow = "#000000"})
hud.text(160, 140, "PRESS A", {align = "center"})
end,
})
screen.add("play", {
enter = function(level) scene.load("level" .. level, {fade = 0.3}) end,
update = function()
if input.pressed("select") then screen.push("pause") end
end,
})
screen.add("pause", {
enter = function() pause_menu = ui.menu{"Resume", "Restart", "Quit", title = "PAUSED"} end,
update = function()
local i, item = pause_menu:update()
if i == 1 or item == "cancel" or input.pressed("select") then
screen.pop()
elseif i == 2 then
screen.go("play", 1)
elseif i == 3 then
screen.go("title")
end
end,
draw = function() pause_menu:draw() end,
})
function game.init()
screen.go("title")
end
screen.go(name, ...)leaves every screen and enters this one;screen.pushenters it over the current one;screen.pop(...)goes back, calling the one below'sresume(...).- Timers and tasks belong to the screen that made them: they wait under a pushed screen and are cancelled when it leaves.
game.pause()/game.pause(false)stops the world without a screen (a hit-stop, a "PAUSED" banner);game.updatestill runs, so it can resume.scene.load(name, {fade = 0.4, color = "#ffffff"})fades out, loads, fades back in, and takes the input meanwhile.signal.on(name, fn [, owner])andsignal.emit(name, ...)let parts of the game talk without knowing each other: a coin emits"coin", the HUD and the sound listen.
UI: menus, dialogs, toasts
local options
function game.init()
options = ui.menu{
"Continue",
{text = "Load", disabled = not save.read("slot1")},
{text = "Credits", on_select = function() ui.toast("Made with Veduta") end},
title = "MENU", index = 1,
}
end
function game.update()
local i = options:update() -- up/down repeat when held, A chooses
if i == 1 then trace("continue") end
end
function game.draw()
options:draw() -- centered unless x, y are given
end
Disabled items are skipped. m:update() returns the index and item chosen this tick, or
nil, and nil, "cancel" on B or Cancel; on_select, on_cancel, on_change do the same as
callbacks.
A dialog types its text in a box at the bottom, pages it three lines at a time and offers choices at the end. Inside a task it waits and returns the choice:
kinds.shopkeeper = {
on_touch = function(e, other)
if not other:has_tag("hero") then return end
task.start(function()
ui.dialog("Tobi", "Lamps! Oil! Rope! Anything you need.")
local choice = ui.dialog("Tobi", "A lamp is 20 gold.", {choices = {"Buy", "Leave"}})
if choice == 1 then
ui.toast("Bought a lamp")
end
end)
end,
}
Outside a task, ui.dialog("Tobi", "Hello", {on_done = function(choice) end}) returns at
once; dialogs queue. While a dialog is open (and while a scene fades), input.* reads as
nothing, so the hero does not walk off mid-sentence; ui.busy() tells when that is.
ui.toast(text [, {sec =, color =}]) shows a short message at the top. ui.style sets the
colors (back, border, text, accent, dim) or a nine-slice panel texture for all
of them, and ui.box(x, y, w, h) draws a box in that style for your own windows.
Effects
kinds.hero = {
init = function(e)
camera.follow(e, {bounds = "map", smooth = 0.15, deadzone = {2, 1}, offset = {1, 0}})
end,
update = function(e)
for _, spike in ipairs(e:overlapping("spike")) do
if e:cooldown("hurt", 1) then
e:flash("#ff4040", 0.2)
e.alpha = 0.6 -- see-through while hurt
timer.after(1, function() e.alpha = 1 end, e)
camera.shake(0.3, 0.25)
particles.burst{x = e.x, y = e.y, color = "#ff4040", color_end = "#402020",
count = 16, speed = {3, 6}, gravity = 20, size = 2, size_end = 1}
end
end
end,
}
kinds.ghost = {
init = function(e)
e.color = "#80c0ff" -- a tint
tween(e, {alpha = 0.3}, 1, "in_out_sine")
end,
}
function game.draw()
for _, enemy in ipairs(scene.tagged("enemy")) do
local x, y, on = camera.to_screen(enemy.x, enemy.y + 0.8)
if on then hud.bar(x - 8, y, 16, 3, enemy.state.hp, 3, {low = "#ff4040"}) end
end
end
camera.follow(e [, options])moves the camera with the entity every tick:bounds("map"or{left, bottom, right, top}),smoothseconds, adeadzone{w, h}, anoffset;camera.follow(nil)stops.camera.shake(strength [, seconds])never shows incamera.get().particles.burst{...}throws squares that fly, fall and fade:angleandspreadaim them (angle = 90, spread = 30is a fountain),life,drag,fade.e.colortints,e.alphafades (tween it),e:flash()paints the entity white for a moment.
Data: records, settings, languages
local function game_over(score, seconds)
local best, new = save.best("score", score) -- higher wins
local fastest = save.best("time", seconds, "low") -- lower wins
ui.toast(new and "NEW RECORD " .. best or "BEST " .. best)
return fastest
end
function game.init()
settings.defaults{volume = 5, shake = true}
end
local function volume_up()
settings.volume = math.min(settings.volume + 1, 10) -- saved at once
end
save.best(key) reads a record, save.best(key, value [, "low"]) stores it when it beats
the old one and returns the record and whether it is new. settings is a table that saves
itself (the save settings); settings.reset() and settings.all() are there too.
Texts in several languages live in lang/<code>.lua beside main.lua:
return {menu = {play = "Play", quit = "Quit"}, hello = "Hello, {name}!"}
return {menu = {play = "Gioca", quit = "Esci"}, hello = "Ciao, {name}!"}
local menu
function game.init()
menu = ui.menu{T("menu.play"), T("menu.quit")}
ui.toast(T("hello", {name = "Mira"}))
end
local function next_language()
local all = lang.list()
local i = table.find(all, lang.get())
lang.set(all[i % #all + 1]) -- remembered in settings.lang
end
T(key [, vars]) falls back to English, then to the key itself, so a missing text shows up
as menu.play instead of an error.
Helpers
| Function | |
|---|---|
math.clamp, math.lerp, math.sign, math.round(x [, step]), math.approach | |
math.distance(x1, y1, x2, y2), math.angle(x1, y1, x2, y2) | degrees from +x |
random.int(a, b), random.float([a, b]), random.chance(p) | seeded, deterministic |
random.choice(list), random.shuffle(list), random.weighted{gold = 3, rock = 1} | |
table.find, table.remove_value, table.copy(t [, deep]) | |
input.repeated(button [, delay [, rate]]), input.held(button) | a cursor that repeats; seconds held |
Snippets
The VS Code extension has a snippet for each of the patterns above. Type the prefix in a
.lua file and press Tab; Tab again moves through the names and numbers to change.
| Prefix | Inserts |
|---|---|
vplatformer | a hero that runs and jumps: a body with gravity, a shorter jump when A is let go, the camera following inside the map |
vtopdown | a hero walking in eight directions, sliding along solid walls, attacking the enemies around it with a cooldown (signal.emit("hit", enemy, 1)) |
vmenu | a title screen with a Play / Options / Quit menu, screen.go("play") on Play |
vdialog | a conversation in a task: lines that wait, a choice that returns its number |
venemy | an enemy that walks to the nearest hero, faces it and bites on touch (signal.emit("hurt", hero, 1)) |
The short forms platformer, topdown, menu, dialog and enemy work too. The hero
snippets and the enemy only emit signals: the game decides what a hit does, with
signal.on("hit", ...) and signal.on("hurt", ...) as in
Enemies, touches and queries.