2D Games
A 2D game in Veduta is a 3D scene seen straight on: an orthographic camera looks down −Z, the game is played in the XY plane (+X right, +Y up), and sprites are thin quads facing the camera. Everything else (entities, kinds, collisions, tests) works as in any game.
The recipe
Camera: orthographic, looking down −Z. In the scene file:
{ "veduta": "scene/1", "camera": { "type": "orthographic", "size": 12, "position": [0, 0, 100], "look_at": [0, 0, 0] }, "background": "#101018", "entities": [] }and from a script,
camera.follow2d(x, y, 12). See Camera.Sprites: the
quadmodel every project has, scaled with the entity'sscale({2, 1, 1}is 2 units wide), and one material per image:{ "veduta": "material/1", "texture": "coin", "unlit": true, "alpha": "cutout", "filter": "nearest" }- Planes: give every plane of the game its own z. For example the background at 0, tiles and actors at 1, a foreground at 2.
- Hitboxes for everything that collides, in the scene file or in
scene.spawn(see Collisions).
Units and pixels
The panel is 320 × 240. With a camera size of 12, one unit is 20 pixels, so a 16 × 16
texture on a quad of scale 0.8 is drawn at exactly 16 × 16 pixels. Choose sizes where
units are whole numbers of pixels (size 12 → 20 px, 15 → 16 px, 24 → 10 px) and sprite
scales that match their textures, and pixel art stays crisp.
Depth and layers
What covers what is decided by z, then by layer:
- Opaque and cutout sprites write depth: among them, the one with the larger z (nearer the camera) covers the others, whatever their order or layer.
- Two opaque sprites at the same z are not ordered by anything you should rely on: put the one that must win at a larger z.
layer(−1000 to 1000, default 0) is the first key of the draw order: lower layers are drawn first.- A translucent sprite (
"alpha": "blend": water, fog, glass) writes no depth. It covers another sprite only when it is nearer and on a layer at least as high. Give translucent sprites a larger z than what they cover and a layer at least as high.
Animation
Draw the frames of an animation side by side in one PNG, a sprite sheet, and let its
texture cut it ("grid") and name the animations ("clips"):
{
"veduta": "texture/1",
"size": [64, 32],
"layers": [
{ "type": "image", "path": "sprites/hero_sheet.png" }
],
"grid": [4, 2],
"clips": {
"walk": { "frames": [0, 1, 2, 3], "fps": 8 },
"idle": { "frames": [4], "fps": 1 },
"hurt": { "frames": [5, 6], "fps": 12, "loop": false, "next": "idle" }
}
}
Here the sheet is 4 × 2 frames of 16 × 16 pixels, counted left to right and top to bottom from 0: the top row walks, the bottom row stands and flinches. The material stays a plain sprite material; it takes the grid from its texture:
{ "veduta": "material/1", "texture": "hero_sheet", "unlit": true, "alpha": "cutout", "filter": "nearest" }
An entity plays a clip by name, and the engine turns the frames at the clip's speed:
kinds.hero = {
update = function(e)
local dx = input.dpad()
if input.pressed("b") then
e:play("hurt") -- starts over, then goes on with idle
elseif dx ~= 0 then
e.x = e.x + dx * 4 * engine.dt
e.anim = "walk" -- already walking: nothing restarts
e:set_scale(dx * 0.8, 0.8, 1) -- a negative x mirrors: face left
elseif e.anim ~= "hurt" then
e.anim = "idle"
end
end,
}
A negative scale mirrors the sprite, so one set of frames serves both directions;
e:face(dx) does it for you, keeping the scale's size. Frames
follow the tick count, so a run always shows the same frame at the same tick, and the
trace records anim and frame: a scenario can check {"entity": "hero", "path": "anim",
"op": "==", "value": "walk"}. e.anim_done tells when a clip that does not loop is over.
To pick frames yourself, set e.frame instead.
A texture's "play" clip runs by itself wherever nothing picks a frame: water that
ripples, a torch that flickers, without a line of code.
Collisions
e:overlapping(tag) compares the entities' boxes. A quad is 1 × 1 × 0.02: flat along Z.
- Sprites at the same z overlap as expected. Sprites at different z do not, even when they cover each other on screen.
- A hitbox gives an entity a collision box of its own, in its local space, that replaces the model's bounds: make it deeper along Z to meet sprites on nearby planes, and smaller than the drawing for fair play (the transparent corners of a round coin should not collect it).
- An entity with a hitbox and no model is an invisible trigger.
- Boxes that only touch do not overlap.
Hitboxes are set in the scene file, in scene.spawn{hitbox = ...}, or later with
e.hitbox = {{-0.4, -0.5, -0.5}, {0.4, 0.5, 0.5}}. A scene with them:
{
"veduta": "scene/1",
"camera": { "type": "orthographic", "size": 12, "position": [0, 0, 100], "look_at": [0, 0, 0] },
"background": "#101018",
"entities": [
{ "name": "sky", "kind": "static", "model": "quad", "material": "sky", "scale": [16, 12, 1] },
{ "name": "ground", "kind": "static", "model": "quad", "material": "ground",
"position": [0, -5, 1], "scale": [16, 2, 1], "hitbox": [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]], "tags": ["solid"] },
{ "name": "hero", "kind": "hero", "model": "quad", "material": "hero",
"position": [-5, -3.5, 1], "hitbox": [[-0.4, -0.5, -0.5], [0.4, 0.5, 0.5]], "tags": ["hero"] },
{ "name": "coin_1", "kind": "coin", "model": "quad", "material": "coin",
"position": [3, -3.5, 1], "scale": [0.5, 0.5, 1], "hitbox": [[-0.3, -0.3, -0.5], [0.3, 0.3, 0.5]], "tags": ["coin"] },
{ "name": "exit", "kind": "static", "position": [7, -3.5, 1], "hitbox": [[-0.5, -1, -0.5], [0.5, 1, 0.5]], "tags": ["exit"] }
]
}
A spawned coin with a fair box:
scene.spawn{kind = "coin", model = "quad", material = "coin", tags = {"coin"},
position = {x, y, 1}, scale = {0.5, 0.5, 1}, hitbox = {{-0.3, -0.3, -0.5}, {0.3, 0.3, 0.5}}}
Platforms and gravity
A body does the physics: give the hero e.body with gravity, tag the ground and walls
solid (entities or map terrains) and platforms you jump up through oneway.
kinds.hero = {
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) -- mirrors the sprite
if e.body.on_ground and input.pressed("a") then
e.body.vy = 14
end
e.anim = e.body.on_ground and (dx ~= 0 and "walk" or "idle") or "jump"
end,
}
The hero needs bounds (a model or a hitbox), and so do the entities that stop it. The
snippet vplatformer in VS Code writes this kind; Game Toolkit
has a top-down version with e:move_and_slide.
Doing it by hand, for a game with its own rules: gravity pulls, A jumps when standing, and the hero never ends a tick inside the ground.
local GRAVITY, JUMP, RUN = -30, 11, 5
local FLOOR = -3.5 -- the hero's y when standing on the ground above
kinds.hero = {
init = function(e)
e.state.vy = 0
end,
update = function(e)
local dx = input.dpad()
e.x = e.x + dx * RUN * engine.dt
local standing = e.y <= FLOOR
if standing and input.pressed("a") then
e.state.vy = JUMP
end
e.state.vy = e.state.vy + GRAVITY * engine.dt
e.y = e.y + e.state.vy * engine.dt
if e.y < FLOOR then
e.y, e.state.vy = FLOOR, 0
end
for _, coin in ipairs(e:overlapping("coin")) do
trace("coin_collected", {coin = coin.name})
coin:despawn()
end
camera.follow2d(e.x, 0, 12)
end,
}
e:move_and_slide(dx, dy) moves by hand against the same solid and oneway tags, and
returns whether it was stopped along x and along y.
The traps
Three mistakes break a 2D game without any error:
- **A
planepart faces +Y**, like a floor: a camera looking down −Z sees it edge-on and draws nothing. Use thequadmodel for sprites. - Flat sprites at different z never collide. Keep colliding sprites at the same z or give them hitboxes with depth.
- **A sprite material needs
unlit,cutoutandnearest.** Lit, it comes out dark; opaque, its transparent pixels are a black rectangle; bilinear, pixel art blurs and gets dark fringes.
veduta inspect scene NAME warns about sprites that may z-fight (opaque sprites
overlapping at the same z).