Veduta

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

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

  2. Sprites: the quad model every project has, scaled with the entity's scale ({2, 1, 1} is 2 units wide), and one material per image:

    { "veduta": "material/1", "texture": "coin", "unlit": true, "alpha": "cutout", "filter": "nearest" }
  3. 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.
  4. 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:

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.

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:

  1. **A plane part faces +Y**, like a floor: a camera looking down −Z sees it edge-on and draws nothing. Use the quad model for sprites.
  2. Flat sprites at different z never collide. Keep colliding sprites at the same z or give them hitboxes with depth.
  3. **A sprite material needs unlit, cutout and nearest.** 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).

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