Your first game
Star catcher: stars fall, a paddle at the bottom catches them, three missed and the game is over. About a hundred lines of Lua and three small files, and every step can be played. It assumes the tool and VS Code are installed (Getting started on Windows).
1. Make the game
In VS Code, Veduta: New Game (Ctrl+Shift+P), Lua, a folder, the name catcher. Or in
a terminal:
veduta init catcher
code catcher
Press F5: the simulator opens with the game's name in the middle of a dark screen. That
is main.lua drawing it; everything else comes from assets/. Close the window (or
Ctrl+Q) to come back.
2. The paddle
A thing on screen is an entity: a model (its shape), a material (its look), a position.
The new game already has the model quad, a 1 × 1 square. The paddle needs a material,
a flat yellow:
{ "veduta": "material/1", "albedo": "#f4c542", "unlit": true }
and the stars a blue one:
{ "veduta": "material/1", "albedo": "#8fd3ff", "unlit": true }
The scene lists what is there when the game starts. Add the paddle to its entities: the
camera shows 16 × 12 units around the centre, so y −5 is near the bottom, and scale
stretches the square to 2 × 0.5.
{
"veduta": "scene/1",
"camera": { "type": "orthographic", "size": 12, "position": [0, 0, 100], "look_at": [0, 0, 0] },
"background": "#101018",
"entities": [
{ "name": "hero", "kind": "hero", "model": "quad", "material": "hero", "position": [0, -5, 1], "scale": [2, 0.5, 1] }
]
}
VS Code completes these files as you type and marks a field that does not exist. The
paddle's kind is hero: its behaviour is Lua's to give. Replace main.lua with the
start of the game:
-- Star catcher: move the paddle with the D-pad, catch the falling stars.
local SPEED = 8 -- the paddle, in units per second
local FALL = 4 -- the stars
local EDGE = 7 -- how far left and right things go
local score, missed = 0, 0
3. Move it
kinds.hero is what every entity of kind hero does, once per tick (20 a second).
input.dpad() is −1, 0 or 1; multiplied by the speed and by engine.dt, the length of a
tick in seconds, it moves the paddle at the same speed whatever the tick rate. Add to
main.lua:
kinds.hero = {
update = function(e)
if missed >= 3 then
return
end
local dx = input.dpad()
e.x = math.max(-EDGE, math.min(EDGE, e.x + dx * SPEED * engine.dt))
for _, star in ipairs(e:overlapping("star")) do
score = score + 1
trace("star_caught", {score = score})
star:despawn()
end
e.state.score = score
end,
}
The loop over e:overlapping("star") is the catch: every star touching the paddle scores
and goes. trace records that it happened, which tests count, and e.state.score is kept
where tests can read it. F5: the arrow keys (or A and D) move the paddle.
4. Falling stars
game.update runs once per tick before the entities. Every twentieth tick (once a second)
it adds a star at a random place along the top. math.random is the game's own generator:
the same run always falls the same way, which is what makes the game testable. Add:
function game.update()
if missed >= 3 then
if input.pressed("a") then
score, missed = 0, 0
scene.load("main")
end
return
end
if engine.tick % 20 == 0 then
scene.spawn{
kind = "star", model = "quad", material = "star", tags = {"star"},
position = {math.random(-EDGE, EDGE), 7, 1}, scale = {0.6, 0.6, 1},
}
end
end
A star falls until it passes the bottom, where it counts as missed. Add:
kinds.star = {
update = function(e)
if missed >= 3 then
return
end
e.y = e.y - FALL * engine.dt
if e.y < -7 then
missed = missed + 1
trace("star_missed", {missed = missed})
if missed == 3 then
trace("game_over", {score = score})
end
e:despawn()
end
end,
}
After three, both kinds stop and game.update waits for A, which loads the scene again
from the start.
5. Score and game over
game.draw runs for every frame shown, after the scene: the place for text over it.
hud.text writes with the console's 8 × 8 font, in pixels from the top left. Add:
function game.draw()
hud.text(4, 4, "SCORE " .. score, "#f4f0e0")
if missed >= 3 then
local msg = "GAME OVER - A TO PLAY"
hud.text((engine.width - 8 * #msg) // 2, engine.height // 2 - 4, msg, "#ff8080")
end
end
And last, a check that holds in every test run: the score never goes below zero. Add:
function game.init()
invariant("score_not_negative", function() return score >= 0 end)
end
Press F5 and play. F1 in the simulator shows how long each tick takes against the budget of the console.
6. Test it
A scenario is a run with scripted buttons and what must be true at the end. Tests run the game without a window, exactly as it plays, and give the same result on every machine.
Holding right for ten ticks moves the paddle right:
{
"veduta": "scenario/1",
"scene": "main",
"seed": 1,
"ticks": 10,
"inputs": [
{ "tick": 1, "press": ["right"] }
],
"expect": [
{ "tick": 10, "entity": "hero", "path": "position.x", "op": ">", "value": 0 }
]
}
Doing nothing loses, once:
{
"veduta": "scenario/1",
"scene": "main",
"seed": 1,
"ticks": 200,
"expect": [
{ "tick": 200, "trace": "game_over", "count_min": 1, "count_max": 1 },
{ "tick": 200, "trace": "star_caught", "count_max": 0 }
],
"screenshots": [60, 200]
}
and A starts again with a score of zero:
{
"veduta": "scenario/1",
"scene": "main",
"seed": 1,
"ticks": 220,
"inputs": [
{ "tick": 200, "press": ["a"] },
{ "tick": 201, "release": ["a"] }
],
"expect": [
{ "tick": 220, "trace": "scene_load", "count_min": 2 },
{ "tick": 220, "entity": "hero", "path": "state.score", "op": "==", "value": 0 }
]
}
Veduta: Test (or veduta test) runs them all:
scenario game_over pass golden new
scenario move pass golden new
scenario restart pass golden new
scenario start pass golden new
PASS
golden new means no outcome is recorded yet: veduta test --update-golden records each
scenario's run as its golden, and from then on a change that alters what happens fails the
test until you record again. Instead of writing inputs by hand,
press F5 in the simulator, play, and F5 again: the run is saved as a scenario to add
expectations to.
7. On the console
Take the console's card out, put it in the PC, and Veduta: Deploy to the Console's
Card (or veduta deploy). Put the card back: the dashboard lists catcher, and A plays
it.