Veduta

Your First Game: Gem Cave

A hero walks through a cave, collects every gem and keeps away from the bats. Along the way you will use textures and materials, build a level from a text map, give entities their behaviour, draw a HUD, add a title screen and a pause menu, and write tests that play the game for you.

The title screen Playing

It assumes the tool and VS Code are installed (Installation). Every file below is complete: copy it as shown.

1. Create the project

In VS Code: Ctrl+Shift+P → Veduta: New Game, choose Lua, a folder and the name gemcave. Or in a terminal:

veduta init gemcave
code gemcave

Press F5: the simulator opens with the game's name on a dark screen. Ctrl+Q (the console's Home button) closes it.

The new project already contains a model quad (a 1 × 1 square, the shape of every sprite) and a scene main, which will be the title screen. See Project Layout for the other files.

2. Sprites

A sprite is an entity with the quad model and a material that shows a texture. Textures are small programs of layers, so no image editor is needed. The hero is a rounded yellow square with two eyes:

{
  "veduta": "texture/1",
  "size": [16, 16],
  "layers": [
    { "type": "rect", "xy": [2, 2], "size": [12, 13], "corner": 4, "color": "#f4c542" },
    { "type": "circle", "center": [5.5, 7], "radius": 1.5, "color": "#101018" },
    { "type": "circle", "center": [10.5, 7], "radius": 1.5, "color": "#101018" }
  ]
}

A gem, a cyan disc with a highlight:

{
  "veduta": "texture/1",
  "size": [16, 16],
  "layers": [
    { "type": "circle", "center": [8, 8], "radius": 6, "color": "#5ce1e6" },
    { "type": "circle", "center": [6, 6], "radius": 2, "color": "#ffffff" }
  ]
}

A bat, purple wings and red eyes:

{
  "veduta": "texture/1",
  "size": [16, 16],
  "layers": [
    { "type": "rect", "xy": [0, 5], "size": [16, 6], "corner": 3, "color": "#8e44ad" },
    { "type": "circle", "center": [8, 8], "radius": 4, "color": "#5b2c6f" },
    { "type": "circle", "center": [6.5, 7.5], "radius": 1, "color": "#ff5555" },
    { "type": "circle", "center": [9.5, 7.5], "radius": 1, "color": "#ff5555" }
  ]
}

Pixels not painted by any layer stay transparent. A material puts a texture on a surface. A sprite's material is always unlit (the scene's light would darken it), cutout (the transparent pixels are not drawn) and nearest (the pixels stay sharp):

{ "veduta": "material/1", "texture": "hero", "unlit": true, "alpha": "cutout", "filter": "nearest" }
{ "veduta": "material/1", "texture": "gem", "unlit": true, "alpha": "cutout", "filter": "nearest" }
{ "veduta": "material/1", "texture": "bat", "unlit": true, "alpha": "cutout", "filter": "nearest" }

Walls need no texture, just a colour:

{ "veduta": "material/1", "albedo": "#3d405b", "unlit": true }

VS Code completes these files as you type and underlines a field that does not exist. More in Graphics Assets.

3. The map

The cave is a text map, one character per tile: # a wall, P where the hero starts, G a gem, B a bat. It lives in its own Lua file, a module that returns the rows:

-- The cave, one character per tile: # wall, P the hero's start, G a gem, B a bat.
return {
  "########################",
  "#P.....#...............#",
  "#......#....G.....B....#",
  "#..G...#...............#",
  "#......####....#####...#",
  "#..............#...#...#",
  "#.....B........#.G.#...#",
  "#..............#...#...#",
  "####....########...#...#",
  "#......................#",
  "#...G..........B.......#",
  "#..................G...#",
  "#......................#",
  "########################",
}

Now replace main.lua. The game begins with its settings, the variables that describe a play, and two helpers: tile finds the map character under a point of the world, and blocked tells whether a box around a point touches a wall. Each tile is 1 × 1 unit; column 1, row 1 of the map is at x = 0, y = 0, and rows go down (−y).

-- Gem Cave: find every gem, keep away from the bats.

local MAP = require("level_map")

local HERO_SPEED = 5   -- units per second
local BAT_SPEED = 3
local HALF = 0.4       -- half the size of a hero or bat box, in units

local lives, gems_left = 3, 0
local paused, over, won = false, false, false
local start_x, start_y = 0, 0

-- The tile under a point: column and row count from 1, like the strings of MAP.
local function tile(x, y)
  local col = math.floor(x + 0.5) + 1
  local row = math.floor(-y + 0.5) + 1
  local line = MAP[row]
  if line == nil then
    return "#"
  end
  local c = line:sub(col, col)
  if c == "" then
    return "#"
  end
  return c
end

-- Whether a box of half-size HALF centered on (x, y) touches a wall.
local function blocked(x, y)
  return tile(x - HALF, y - HALF) == "#" or tile(x + HALF, y - HALF) == "#"
      or tile(x - HALF, y + HALF) == "#" or tile(x + HALF, y + HALF) == "#"
end

require("level_map") runs level_map.lua once and returns what it returned. A module name with dots (require("levels.cave")) is a path under the game's folder.

4. Building the level

A scene is what exists when a level starts. The level's scene holds only a camera and one invisible entity, builder, whose kind level builds the rest:

{
  "veduta": "scene/1",
  "camera": { "type": "orthographic", "size": 12, "position": [1, -1, 100], "look_at": [1, -1, 0] },
  "background": "#14141f",
  "entities": [
    { "name": "builder", "kind": "level" }
  ]
}

The camera is orthographic, looks down −Z and shows 12 units of height (16 of width on the 4:3 panel): a 2D view. A kind is a table of functions shared by every entity whose kind names it. init runs when such an entity is loaded, so building the level there means it is rebuilt every time the scene is loaded. Add to main.lua:

kinds.level = {
  init = function()
    lives, gems_left = 3, 0
    paused, over, won = false, false, false
    for row, line in ipairs(MAP) do
      for col = 1, #line do
        local c = line:sub(col, col)
        local x, y = col - 1, -(row - 1)
        if c == "#" then
          scene.spawn{model = "quad", material = "wall", position = {x, y, 0}}
        elseif c == "G" then
          gems_left = gems_left + 1
          scene.spawn{name = "gem_" .. gems_left, model = "quad", material = "gem",
            tags = {"gem"}, position = {x, y, 1}, scale = {0.6, 0.6, 1}}
        elseif c == "B" then
          scene.spawn{kind = "bat", model = "quad", material = "bat", tags = {"bat"},
            position = {x, y, 1}, scale = {0.8, 0.8, 1}, state = {dir = 1}}
        elseif c == "P" then
          start_x, start_y = x, y
          scene.spawn{name = "hero", kind = "hero", model = "quad", material = "hero",
            position = {x, y, 1}, scale = {0.8, 0.8, 1}}
        end
      end
    end
  end,
}

scene.spawn adds an entity. Walls sit at z = 0 and the actors at z = 1, in front of them. Tags (gem, bat) let the game find entities by what they are, and state is a table of the entity's own values. See Entities.

5. The hero

update runs once per tick (20 times a second) for every entity of the kind. input.dpad() gives the D-pad as two numbers, −1, 0 or 1; multiplied by the speed and by engine.dt, the length of a tick in seconds, it moves the hero in units per second. Each axis moves on its own, so the hero slides along a wall instead of stopping dead.

e:overlapping(tag) lists the entities touching this one: every gem touched is collected, and a bat sends the hero back to the start. trace records that something happened, which tests count.

kinds.hero = {
  update = function(e)
    if paused or over or won then
      return
    end
    local dx, dy = input.dpad()
    local step = HERO_SPEED * engine.dt
    -- Each axis on its own, so the hero slides along a wall instead of sticking to it.
    if dx ~= 0 and not blocked(e.x + dx * step, e.y) then
      e.x = e.x + dx * step
    end
    if dy ~= 0 and not blocked(e.x, e.y + dy * step) then
      e.y = e.y + dy * step
    end
    camera.follow2d(e.x, e.y, 12)

    for _, gem in ipairs(e:overlapping("gem")) do
      gem:despawn()
      gems_left = gems_left - 1
      trace("gem_collected", {left = gems_left})
      if gems_left == 0 then
        won = true
        trace("won", {lives = lives})
      end
    end

    if #e:overlapping("bat") > 0 then
      lives = lives - 1
      trace("hit", {lives = lives})
      e:set_position(start_x, start_y, 1)
      if lives == 0 then
        over = true
        trace("game_over", {})
      end
    end
    e.state.lives = lives
  end,
}

camera.follow2d keeps the hero in the middle of the screen. e.state.lives copies the lives where tests can read them.

6. Bats

A bat flies left and right and turns back at walls. Its direction is its own, so it lives in its state, which scene.spawn gave it:

kinds.bat = {
  update = function(e)
    if paused or over or won then
      return
    end
    local step = BAT_SPEED * engine.dt * e.state.dir
    if blocked(e.x + step, e.y) then
      e.state.dir = -e.state.dir
    else
      e.x = e.x + step
    end
  end,
}

7. Title, pause and the end

game.update runs once per tick before every entity: the place for what concerns the whole game. On the title scene (main) A starts the level; once the game is won or lost, A goes back to the title. Select is the console's menu button: it pauses, and Cancel (or Select again) resumes. Both kinds above already do nothing while paused is true.

function game.update()
  if scene.name() == "main" then
    if input.pressed("a") then
      scene.load("level")
    end
    return
  end
  if over or won then
    if input.pressed("a") then
      scene.load("main")
    end
    return
  end
  if input.pressed("select") then
    paused = not paused
  elseif paused and input.pressed("cancel") then
    paused = false
  end
end

input.pressed is true only on the tick the button goes down, so holding A does not start the game over and over. The console's Home button never reaches the game: it always returns to the console's menu.

8. The HUD

game.draw runs for every frame, after the scene is drawn: text and rectangles go over it, in pixels from the top left of the 320 × 240 frame. The font is 8 × 8 pixels per character, times scale.

local function centered(y, text, color, scale)
  scale = scale or 1
  hud.text((engine.width - 8 * scale * #text) // 2, y, text, color, scale)
end

function game.draw()
  if scene.name() == "main" then
    centered(80, "GEM CAVE", "#5ce1e6", 3)
    centered(150, "PRESS A", "#f4f0e0")
    return
  end
  hud.rect(0, 0, engine.width, 12, "#000000b0")
  hud.text(4, 2, "GEMS " .. gems_left, "#5ce1e6")
  hud.text(engine.width - 76, 2, "LIVES " .. lives, "#ff8080")
  if paused then
    hud.rect(80, 90, 160, 60, "#101018e0")
    centered(104, "PAUSED", "#f4f0e0", 2)
    centered(128, "CANCEL TO PLAY", "#a0a0b0")
  elseif won then
    centered(110, "ALL GEMS! A FOR TITLE", "#5ce1e6")
  elseif over then
    centered(110, "GAME OVER - A FOR TITLE", "#ff8080")
  end
end

Last, a rule that must hold in every test run, whatever the buttons: lives stay between 0 and 3. game.init runs once, when the game starts.

function game.init()
  invariant("lives_in_range", function() return lives >= 0 and lives <= 3 end)
end

Press F5 and play: A on the title, the arrow keys (or WASD) to move, Enter (Select) to pause, Escape (Cancel) to resume. Saving a file restarts the game with the change.

9. Tests that play the game

A scenario loads a scene, presses buttons at given ticks and checks what must be true. Tests run without a window and give the same result on every machine, so a scenario that passes on your PC passes on the console. See Testing.

Walking right, the hero stops at the first wall (the wall's edge is at x = 6.5):

{
  "veduta": "scenario/1",
  "scene": "level",
  "seed": 1,
  "ticks": 40,
  "inputs": [
    { "tick": 1, "press": ["right"] }
  ],
  "expect": [
    { "tick": 40, "entity": "hero", "path": "position.x", "op": ">", "value": 5.5 },
    { "tick": 40, "entity": "hero", "path": "position.x", "op": "<", "value": 6.5 }
  ]
}

Two units down and two to the right is the first gem:

{
  "veduta": "scenario/1",
  "scene": "level",
  "seed": 1,
  "ticks": 40,
  "inputs": [
    { "tick": 1, "press": ["down"] },
    { "tick": 9, "release": ["down"] },
    { "tick": 9, "press": ["right"] },
    { "tick": 17, "release": ["right"] }
  ],
  "expect": [
    { "tick": 40, "trace": "gem_collected", "count_min": 1, "count_max": 1 },
    { "tick": 40, "entity": "hero", "path": "state.lives", "op": "==", "value": 3 }
  ],
  "screenshots": [0, 20]
}

Walking along the bat's row costs a life:

{
  "veduta": "scenario/1",
  "scene": "level",
  "seed": 1,
  "ticks": 100,
  "inputs": [
    { "tick": 1, "press": ["down"] },
    { "tick": 21, "release": ["down"] },
    { "tick": 21, "press": ["right"] }
  ],
  "expect": [
    { "tick": 100, "trace": "hit", "count_min": 1 },
    { "tick": 100, "entity": "hero", "path": "state.lives", "op": "<", "value": 3 }
  ]
}

While paused, the D-pad does nothing:

{
  "veduta": "scenario/1",
  "scene": "level",
  "seed": 1,
  "ticks": 30,
  "inputs": [
    { "tick": 1, "press": ["select"] },
    { "tick": 2, "release": ["select"] },
    { "tick": 3, "press": ["right"] }
  ],
  "expect": [
    { "tick": 30, "entity": "hero", "path": "position.x", "op": "==", "value": 1 }
  ],
  "screenshots": [30]
}

And A on the title loads the level:

{
  "veduta": "scenario/1",
  "scene": "main",
  "seed": 1,
  "ticks": 10,
  "inputs": [
    { "tick": 1, "press": ["a"] },
    { "tick": 2, "release": ["a"] }
  ],
  "expect": [
    { "tick": 10, "trace": "scene_load", "count_min": 2, "count_max": 2 },
    { "tick": 10, "entity": "hero", "path": "position.x", "op": "==", "value": 1 }
  ]
}

Veduta: Test (or veduta test in a terminal) runs them all, together with the start scenario the project came with:

scenario bat_hit              pass  golden new
scenario first_gem            pass  golden new
scenario pause                pass  golden new
scenario start                pass  golden new
scenario title                pass  golden new
scenario walk                 pass  golden new
PASS

golden new means the scenario has no recorded outcome yet. Record them once the game plays as you want:

veduta test --update-golden

From then on, a change that alters anything that happens in a scenario fails the test until you look at it and record again (see Testing). Instead of writing inputs by hand you can record them: in the simulator press F5, play, and F5 again saves the run as a scenario.

10. On the console

Put the console's SD card in the PC (it appears as a drive named VEDUTAOS) and run Veduta: Deploy to the Console's Card (or veduta deploy). Put the card back in the console: the dashboard lists gemcave, and A plays it. See Publishing to the Console.

Ideas to go further

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/.