2D games
A 2D game on Veduta is a 3D scene seen straight on: the camera is orthographic and looks
down −Z, the game is played in the XY plane (+X to the right, +Y up), and sprites are thin
quads facing the camera. Nothing else changes: entities, kinds, ctx.Overlapping,
collision events, invariants, scenarios, render, query and simulate all work as in
a 3D game. The console's panel is 320×240 pixels at 20 frames a second.
This page is the recipe, then the three traps that break a 2D game without an error.
Camera
{
"veduta": "scene/1",
"camera": { "type": "orthographic", "size": 12, "position": [0, 0, 100], "look_at": [0, 0, 0] },
"entities": []
}
sizeis the height of the view in world units; the width is size × aspect ratio (16 units on the 4:3 panel). Pixel art stays sharp when one unit is a whole number of pixels: on a 240-pixel-tall frame,size12 is 20 pixels per unit, 24 is 10, 15 is 16.- The camera sits at z = 100 and the defaults
near0.1 andfar200 keep everything with z from −100 to 99.9 visible. A larger z is nearer the camera. - Its up direction is +Y, so +Y is up on screen.
- To follow the hero, replace the camera during
Updatewithscene.Camera2D(center gmath.Vec2, height float32), which returns exactly this camera looking at(center.X, center.Y, 0):
p := hero.WorldPosition()
ctx.Scene.Camera = scene.Camera2D(gmath.V2(p.X, p.Y), 12)
Sprites
A new project's template ships the two assets a sprite needs:
- model
quad(assets/models/quad.vmodel): a box of 1 × 1 × 0.02 units pivoted at its center. Size a sprite with the entity'sscale([2, 1, 1]is 2 units wide); its +Z face shows the material's texture once, upright, whatever the scale (UVs are computed on the unscaled model). - material
sprite(assets/materials/sprite.vmat):"unlit": true,"alpha": "cutout","filter": "nearest". Copy it once per sprite image and add"texture":
{ "veduta": "material/1", "texture": "coin", "unlit": true, "alpha": "cutout", "filter": "nearest" }
A texture with transparent pixels (#00000000, or no layer there) gives the sprite its
shape. To animate, draw the frames side by side in one texture (a sprite sheet) and give
its material a grid; the entity's Frame picks the frame shown:
{ "veduta": "material/1", "texture": "hero_walk", "grid": [6, 1], "unlit": true, "alpha": "cutout", "filter": "nearest" }
and in Update, e.Frame = int(ctx.Tick/4) % 6 (in Lua, e.frame = engine.tick // 4 % 6).
Give a sheet's texture "mipmaps": false, so frames never bleed into each other.
A texture can cut itself into frames instead ("grid") and name its animations
("clips", docs/texture.md); an entity then plays one by
name: e.SetAnim("walk") in Go (e.Play starts it over), e.anim = "walk" in Lua, or
"anim": "walk" in the scene file. The engine sets the frame every tick from the tick
count, so it is deterministic and in the trace. A texture's play clip runs by itself
wherever nothing picks a frame: water, torches, the tiles of a map.
Maps
The ground of a game seen from above (a farm, a town, a dungeon floor) is a tile map: a
.vmap whose layers are rows of characters, one per cell, each a terrain drawn with a
texture (docs/map.md). A scene names it with "map"; the engine draws it under
the entities, terrains with an edge get wandering borders over their lower neighbours,
and the game reads and paints cells (map.get, map.set, map.has(x, y, "solid")).
Depth and layers
What covers what is decided by z and by layer. Every sprite is depth tested, but only
opaque and cutout sprites write depth:
- Give every plane of the game its own z: for example background 0, tiles and actors 1, foreground 2. Among opaque and cutout sprites, one at a larger z covers the ones behind it, whatever their layers and their order in the scene file.
- Two opaque (or cutout) 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(an integer in [−1000, 1000], default 0) is the first key of the draw order: lower layers are drawn first; within a layer opaque parts are drawn in id order, then blended parts back to front along the view axis.- A translucent (
"alpha": "blend") sprite writes no depth, so it covers another sprite only when it is nearer the camera and on a layer at least as high. At the same z as an opaque sprite, or behind it, it is hidden. On a lower layer than an opaque or cutout sprite it is painted over by that sprite whatever their z, without any error: the opaque sprite is drawn later and nothing in the depth buffer stops it. - Between two translucent sprites the layer decides, then depth: smoke on layer 1 is drawn over water on layer 0 at the same z, and would be even if it were farther away.
- So give translucent sprites (fog, water, a foreground overlay) a larger z than what they cover and a layer at least as high: for example the actors on layer 0 at z = 1, water on layer 0 and mist on layer 1, both at z = 2.
Collisions
Collisions compare entity AABBs (ctx.Overlapping(e), the collision trace event, the
no_overlap:tagA,tagB invariant). Give every sprite that takes part a hitbox: a box in
the entity's local space, scaled, turned and moved with it, that replaces the model's
bounds as its AABB.
- Give the hitbox depth along Z. The quad itself is 0.02 units deep, so without hitboxes a
hero at z = 1 and a wall at z = 0.9 never touch.
[[-0.4, -0.5, -0.5], [0.4, 0.5, 0.5]]reaches half a unit in front of and behind the sprite: it meets sprites on nearby planes but not a background at z = 0. - Make it smaller than the drawing for fair play (the transparent corners of a round coin should not collect it).
- An entity without a model and with a hitbox is an invisible trigger zone.
- Touching boxes do not overlap, and two
staticentities never collide.
HUD
Draw score, lives and menus in Draw on top of the scene, in pixels with the origin at the
top-left corner of the frame:
func (g *Game) Draw(ctx *veduta.Context, dl *gfx.DrawList) {
hud := ctx.HUD(dl)
hud.Rect(gmath.R(0, 0, float32(ctx.Width), 14), 0xc0000000)
ctx.Text(hud, 4, 3, 1, fmt.Sprintf("SCORE %d", g.Score), 0xfff4f0e0)
hud.End()
}
The HUD is not depth tested, never hides an entity from query --at and never appears in
the ids buffer. ctx.Width and ctx.Height are the size of the frame being drawn in
Draw, and the project's resolution in Init and Update (in the player and in every
headless command alike), so a layout computed in Update is for the frame the game is
designed for and never changes the trace. In the player, Draw receives frames of the
panel's size divided by VEDUTA_SCALE (320×240 on the reference panel), which can differ
from resolution: take the HUD's final positions from ctx.Width and ctx.Height in
Draw.
Input
A game reads the console's eight buttons, and scenarios script them with press and
release (the full table, with the keyboard that stands in for the pad, is in the api
topic):
| Button | Game sees | Scenario name |
|---|---|---|
| D-pad | veduta.ButtonUp, ButtonDown, ButtonLeft, ButtonRight; in.DPad() | up, down, left, right |
| A, B | veduta.ButtonA, veduta.ButtonB | a, b |
| Select | veduta.ButtonSelect: the game's menu | select |
| Cancel | veduta.ButtonCancel: back | cancel |
Home returns to the console's home and never reaches the game.
const Speed = 4 // units per second
func updateHero(ctx *veduta.Context, e *scene.Entity, in veduta.Input) {
d := in.DPad() // +Y is up
// A product added to a value is wrapped in float32(...) so that arm64 cannot fuse
// the two into one instruction and change the trace.
e.Transform.Position.X += float32(d.X * Speed * ctx.DT)
e.Transform.Position.Y += float32(d.Y * Speed * ctx.DT)
for _, o := range ctx.Overlapping(e) {
if o.HasTag("coin") {
ctx.Trace("coin_collected", map[string]any{"coin": o.Name})
ctx.Despawn(o)
}
}
p := e.WorldPosition()
ctx.Scene.Camera = scene.Camera2D(gmath.V2(p.X, p.Y), 12)
}
The three silent traps
1. A plane faces +Y
A model part of shape plane lies in the XZ plane facing +Y, like a floor. Seen by a
camera looking down −Z it is edge-on and draws nothing. Turning the entity
"rotation_deg": [90, 0, 0] makes it face +Z, toward the camera; [-90, 0, 0] makes it
face away, and back-face culling hides it. Even turned the right way it has no thickness,
which is trap 2. Use the quad model instead.
2. A quad with no thickness does not reliably collide
AABB.Overlaps is strict on all three axes: boxes that only touch do not overlap, and a
box with no depth only ever touches another box in the same plane. Two sprites whose
models are flat along Z (a turned plane, or any model with no depth) cannot be relied on
to produce a collision event, to appear in ctx.Overlapping or to violate no_overlap:
where they are exactly flat they never do, without any error. Whether they are depends on
where they stand: at z = 0 the rounding of the 90° rotation leaves a turned plane a few
1e-8 units of depth, so coplanar planes there do overlap, while at z = 1 the same planes
are exactly flat and do not. Give the sprite depth: use the quad model, and better, a
hitbox with depth (see Collisions).
3. A sprite material needs unlit, cutout and nearest
"unlit": true. A lit quad facing the camera receives the scene's light at a grazing angle (with the default light, n · −d̂ ≈ 0.27) and comes out dark and drab."alpha": "cutout". Withopaquethe transparent pixels are drawn as a black rectangle and belong to the sprite inqueryand the ids buffer. Withblendthe sprite writes no depth and no ids:query --atnames whatever is behind it, and an opaque sprite at the same z, or on a higher layer, hides it (see Depth and layers). Keepblendfor sprites that really are translucent."filter": "nearest". Bilinear filtering blurs pixel art when it is scaled up, and because transparent texels are stored black, it gives a sprite's edges a dark fringe.
Checking a 2D game headless
render --camera scenedraws the game's own view; thefrontpreset also looks down −Z and frames the whole scene.query --at x,ynames the sprite under a pixel.simulatedraws the trajectory tile in the XY plane (labelledtrajectories (xy)) whenever the scene camera is orthographic and looks along −Z.- The trace's
aabbof an entity with a hitbox is the hitbox in world space, so scenarios can check it (aabb.min.x). inspect scenereportsSCENE_UNLIT(info) for a scene of unlit sprites: expected. It warnsSCENE_ZFIGHT_RISKwhen opaque or cutout sprites overlap at the same z (give them different z), andSCENE_OVERLAPwhen the AABBs of twostaticsprites intersect. Two translucent sprites stacked at the same z bylayer, likewaterandmistbelow, get neither: they write no depth, so they cannot z-fight.
Full example
assets/scenes/main.vscene, with the template's quad, one material per sprite
image, and a game kind hero whose behaviour is updateHero above:
{
"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]] },
{ "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", "important"] },
{ "name": "coin_1", "kind": "static", "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": "water", "kind": "static", "model": "quad", "material": "water",
"position": [5, -4, 2], "scale": [4, 1, 1] },
{ "name": "mist", "kind": "static", "model": "quad", "material": "mist",
"position": [5, -3.5, 2], "scale": [6, 2, 1], "layer": 1 }
]
}
water and mist are translucent ("alpha": "blend") and nearer than the actors; mist
is on layer 1 so it is drawn over water, which is at the same z. A scenario that walks the
hero into the coin:
{
"veduta": "scenario/1",
"scene": "main",
"ticks": 60,
"inputs": [
{ "tick": 1, "press": ["right"] },
{ "tick": 40, "release": ["right"] }
],
"expect": [
{ "tick": 40, "entity": "hero", "path": "position.x", "op": ">", "value": 2.5 },
{ "tick": 60, "trace": "coin_collected", "count_min": 1, "count_max": 1 }
],
"invariants": ["finite_positions"],
"screenshots": [0, 40]
}