Veduta

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,
}

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 taskGoes 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
QueryReturns
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

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

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.

PrefixInserts
vplatformera hero that runs and jumps: a body with gravity, a shorter jump when A is let go, the camera following inside the map
vtopdowna hero walking in eight directions, sliding along solid walls, attacking the enemies around it with a cooldown (signal.emit("hit", enemy, 1))
vmenua title screen with a Play / Options / Quit menu, screen.go("play") on Play
vdialoga conversation in a task: lines that wait, a choice that returns its number
venemyan 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.

Veduta v2 (Lua games, release candidates). This wiki is built from wiki/ in the engine's repository, where its examples are tested, and published with every engine release on https://veduta.roomve.it/docs/.