Veduta

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": []
}
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:

{ "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:

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.

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):

ButtonGame seesScenario name
D-padveduta.ButtonUp, ButtonDown, ButtonLeft, ButtonRight; in.DPad()up, down, left, right
A, Bveduta.ButtonA, veduta.ButtonBa, b
Selectveduta.ButtonSelect: the game's menuselect
Cancelveduta.ButtonCancel: backcancel

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

Checking a 2D game headless

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