Veduta game API
A Veduta game is a Go program whose main calls veduta.Run(&game.Game{}). The same
binary plays on the console (a linux/arm64 board drawing on its panel's framebuffer and
reading a gamepad) and, with -headless, renders, simulates and answers queries for the
veduta tool on any machine, including a server with no display. Game logic lives only
in the game binary; the tool never contains game code.
package main
import (
"github.com/riftbane/veduta-engine"
"mygame/game"
)
func main() { veduta.Run(&game.Game{}) }
Interfaces
type Game interface {
Init(ctx *veduta.Context) error // once, after the first scene is loaded
Update(ctx *veduta.Context, in veduta.Input) // exactly once per tick, before behaviours
Draw(ctx *veduta.Context, dl *gfx.DrawList) // once per rendered frame, after the scene
}
type Behaviour interface {
Update(ctx *veduta.Context, e *scene.Entity, in veduta.Input)
}
func RegisterKind(name string, ctor func(*scene.Entity) veduta.Behaviour)
RegisterKindbinds a scene entitykindto the constructor of its behaviour. Call it from aninitfunction. Names are lowercase[a-z0-9_-];static,cameraandlightare built-in kinds without behaviour. Loading a scene with an unregistered kind fails.- The constructor runs when an entity of that kind is loaded or spawned; it may set
e.State.veduta.BehaviourFuncadapts a plain function. - Behaviour values must be stateless: keep per-entity state in
e.State(a pointer to a struct with exported fields). The trace records it asstate.<json field>and snapshots save it withencoding/gob.
Context
| Member | Meaning |
|---|---|
Scene *scene.Scene | the world: Find(name), Get(id), Tagged(tag), Entities() (id order) |
Tick uint64 | 0 during Init, then 1, 2, … during Update |
RNG *sim.RNG | seeded xoshiro256**: Float32(), Range(lo, hi), Intn(n), Bool(), Chance(p) — the only allowed randomness |
DT float32 | seconds per tick (1 / tick_rate, 1/20 by default) |
Headless bool | true when run by the tool |
Project *asset.Project | the parsed veduta.json |
Width, Height int | a frame size in pixels, never 0 once Init runs. In Draw: the size of the frame being drawn (the panel's size divided by VEDUTA_SCALE in the player, --width×--height in render, the screenshot tile in simulate). In Init, Update and behaviours: the project's resolution (the frame the game is designed for) in every mode (player, render, simulate, snapshot), so logic that reads them gives the same trace everywhere and HUD layout can be computed during Update; in the player it can differ from the frame Draw receives. |
Font, FontTexture | built-in 8×8 font for HUD text |
Trace(event, fields) | emit a game event into the current tick's trace |
Invariant(name, pred) | register a predicate checked after every tick when listed |
RegisterState(codec) | save/restore the game's own state in snapshots |
Spawn(tmpl) *scene.Entity | add an entity (next id); its behaviour starts next tick |
SpawnPrefab(name, origin, rotation, prefix) | add a prefab's entities with the min corner of its footprint at origin, turned by 0, 90, 180 or 270 degrees about +Y as a world places it, named <prefix>_<entity> (the prefab's name when prefix is empty) and parented as the prefab says; returns them in prefab order |
Despawn(e) | remove e and its children at the end of the tick |
LoadScene(name) | replace the scene (ids restart at 1), e.g. to reset a level |
SetModel(name, m) error | add or replace a model the game builds at runtime (a voxel chunk's mesh): entities name it like an asset model, with culling, LODs and DrawDistance; uploaded at the next frame. The name contains : and does not start with world:; pass a new *asset.Model to change it (the engine re-uploads when the pointer changes). Not saved in snapshots and kept across LoadScene |
RemoveModel(name), Model(name) | forget a runtime model; look one up |
Map() *tilemap.Map | the scene's tile map (docs/map.md), nil without one: Get(layer, x, y), Set(layer, x, y, terrain) error (a map_set event), Has(layer, x, y, tag) (layer −1: any), CellAt(x, y), Center(x, y), Layer(name), Size(), Source() (its objects) |
LoadMap(name) error | replace the scene's tile map with a map as its file describes it ("" removes it); the entities stay; a map_load event |
Clip(e, name) *asset.Clip, Clips(e) | a clip of the textures e draws with, and their names; play one with e.SetAnim(name) (unless it plays) or e.Play(name) (from the start): the engine sets e.Frame every tick |
Overlapping(e) | live entities whose AABB overlaps e's (last tick's bounds) |
Texture(name) | a texture asset's handle and size in texels, for Batch.Image and Batch.NineSlice in Draw (ok false for an unknown texture) |
ReadSave(name), WriteSave(name, data), RemoveSave(name), SaveNames() | the game's saves: JSON objects or arrays of at most 1 MiB under valid names. The player keeps them as files in VEDUTA_SAVE_DIR (default out/saves of the project; the console sets it to the card); headless runs keep them in memory, starting from the scenario's saves. Writes and removals are trace events save_write and save_remove |
HUD(dl) *sprite.Batch | start a HUD batch covering the frame; call End() |
Text(b, x, y, scale, s, color) | draw text with the built-in font |
type StateCodec interface {
SaveState(enc *gob.Encoder) error
LoadState(dec *gob.Decoder) error
}
Input
veduta.Input is a value type, identical whether it comes from the console or a script.
| Field / method | Meaning |
|---|---|
Pressed, Held, Released | button sets (sim.Buttons) |
Down(b), JustPressed(b), JustReleased(b) | button queries, b one of the constants below |
DPad() gmath.Vec2 | the D-pad as a direction: X −1 (left), 0 or +1 (right), Y −1 (down), 0 or +1 (up); opposite directions cancel; a diagonal is (±1, ±1) |
A button pressed and released within one tick appears in Pressed and Released but not
in Held.
The console's controls
The console has a D-pad, A, B, Select, Cancel and Home. A game sees eight buttons; Home is the console's, and no game can see or swallow it: pressed, it opens the console's overlay over the game (the game keeps running, with no button held); held for a second, it leaves the game, even one that has stopped answering.
| Button | Constant | Scenario name | Meant for | Keyboard | Gamepad |
|---|---|---|---|---|---|
| D-pad | veduta.ButtonUp, ButtonDown, ButtonLeft, ButtonRight | up, down, left, right | moving, choosing | arrows, W A S D | D-pad, hat, or a stick where the D-pad is not four buttons |
| A | veduta.ButtonA | a | the main action, confirm | Space, Z | A (BTN_SOUTH) |
| B | veduta.ButtonB | b | the second action | X, Shift | B (BTN_EAST) |
| Select | veduta.ButtonSelect | select | the game's menu | Enter, Tab | Start |
| Cancel | veduta.ButtonCancel | cancel | back, close a menu | Escape, Backspace | Select, KEY_BACK (the handheld's Cancel) |
| Home | none | none | the console's overlay; held, leave the game | Home (Ctrl+Q leaves at once) | Home (BTN_MODE, KEY_HOMEPAGE), or Select and Start together |
A USB pad has no Home button, so Select and Start pressed together are its Home; alone,
its Start is the game's menu and its Select is Cancel. A pad's other buttons (X, Y,
shoulders) and sticks beside a four-button D-pad are ignored. A program that plays games
without an overlay (veduta run) leaves on a press of Home.
Cameras
ctx.Scene.Camera is the camera the player and render --camera scene draw with; a game
may replace it during Update.
scene.Camera2D(center gmath.Vec2, height float32) scene.Camerais the camera of a 2D game played in the XY plane: orthographic, looking down −Z at(center.X, center.Y, 0)from z =scene.Camera2DDistance(100), +X to the right and +Y up on screen,heightworld units visible vertically (the width follows from the aspect ratio), near 0.1 and far 200, so z from −100 to 99.9 is visible and a larger z is nearer the camera. Follow a hero withp := hero.WorldPosition(); ctx.Scene.Camera = scene.Camera2D(gmath.V2(p.X, p.Y), 12). It is the same camera as a scene file's"type": "orthographic", "size": 12, "position": [x, y, 100], "look_at": [x, y, 0]. The2ddocs topic is the recipe for a 2D game: camera, sprites, layers, hitboxes, HUD, pad input and the traps.Camera.LookFrom(eye, yawDeg, pitchDeg)places a camera ateyelooking along yaw and pitch, keeping the projection.
Entities
scene.Entity fields: ID, Name, Kind, Transform (Position, Rotation quaternion,
Scale, relative to Parent), Model, Material, Tags, AABB (world bounds of the
hitbox, else of the model, recomputed after every tick), Visible, State, Parent,
Hitbox *gmath.AABB (the scene file's hitbox: a local-space box that replaces the model
bounds as the source of AABB, so collisions, Overlapping, no_overlap and the trace's
aabb use it; nil for none; Spawn copies it), Layer int (the scene file's layer:
the first key of the draw order, lower layers drawn first; within a layer opaque parts in
id order, then blended parts back to front by depth along the view axis; opaque and cutout
parts write depth, so among them the nearest is in front whatever the layer, while blended
parts write none, so an opaque part on a higher layer is drawn over a blended part on a
lower one whatever their depth). Useful methods: WorldPosition(), World(), HasTag(t),
Alive(). Use gmath for math: its Sin/Cos/Atan2 are deterministic on every platform;
never use math.Sin in game logic.
Coordinates: right-handed, Y up, −Z forward, meters. Angles are degrees in files and
radians in code. rotation_deg: [x, y, z] means R = Ry·Rx·Rz.
Tick timeline
- Tick 0: the scene is loaded (ids 1…n in scene-file order),
Initruns, then the engine records tick 0 (eventsscene_loadand any spawns fromInit). - Each tick t = 1, 2, …:
Game.Update(ctx, in), then every live entity's behaviour in id order (entities spawned during the tick start next tick), then despawns are applied, transforms and AABBs are recomputed, collisions and invariants are checked, and tick t is recorded.
Input events of a script at tick t are part of the Input of tick t; events at tick 0 apply
before the first update and appear in tick 1. The simulation runs at tick_rate (20 Hz by
default); there is no interpolation and the player renders once per tick. With
VEDUTA_PERF=<seconds> the player writes its frame times (below). The player collects garbage
less often than a Go program does by default, at a heap five times what is live (GOGC=400;
GOGC in the environment overrides it), and right after a scene or world loads, where the
load already stalls a frame.
Frame times on a console
With VEDUTA_PERF=<seconds> (a console's dashboard sets 10 for every game) the player
writes, that often, one line like this one to stderr, which a console copies to its kernel log:
veduta: perf: gpu (VC4 V3D 2.1), 200 frames in 10.0 s, update 2.10 ms, render 18.40 ms, present 6.20 ms, worst 41.00 ms, 0 over 50 ms, stepped 200, rendered 200, build 3.10 ms, submit 4.00 ms, readpixels 9.00 ms, convert 2.30 ms, keep 0.20 ms, overlay 0.01 ms, pack 2.00 ms, write 3.99 ms, commands 42.0, gl calls 812.0, gc 3, alloc 7.5 KB, self user 55.0%, self system 10.0%, cpu user 56.0%, cpu system 20.0%, cpu softirq 3.0%, cpu idle 20.0%
Fields are separated by ,, each a name and a value; the first ones, up to over, are
the line as it has always been, and keep their order. Times are means per frame over the
period, in milliseconds.
| Field | Meaning |
|---|---|
software, gpu (…) | what drew the frames: the processor, or the GPU through its GL implementation |
frames in … s | frames drawn in the period, and its length |
update | the tick: Game.Update, behaviours, physics, invariants |
render | the frame drawn: build + submit + readpixels + convert |
present | the frame shown: keep + overlay + pack + write |
worst | the slowest frame, update, render and present together |
over … ms | frames slower than a tick (1000 / tick_rate ms) |
stepped, rendered | ticks run and frames drawn in the period |
build | the camera, uploading the models the game changed, the draw list of the scene and of game.draw, the console's overlay |
submit | the draw commands sent: on the GPU, the GL calls (made on the GL thread in End, in one handoff a frame); in software, all of the rasterization |
readpixels | on the GPU, the wait for it to finish and the read of the pixels back (the driver untiles and copies them) |
convert | on the GPU, the pixels read turned into the frame's colors |
keep | the copy of the frame kept to show under an error |
overlay | the player's own overlay: F1, notices, the error |
pack | the frame packed into the panel's format (RGB565), where the panel says how long it took; 0 elsewhere, where write has it |
write | the frame written to the panel's device. An SPI panel's driver sends it on later, in a kernel thread of its own |
commands | draw commands per frame |
gl calls | OpenGL ES calls per frame; 0 in software |
gc | garbage collections in the period |
alloc | KB allocated per frame |
self user, self system | the player's processor time in user space and in the kernel, as a share of all the processors' time in the period (/proc/self/stat; on Linux only, like the next four) |
cpu user, cpu system, cpu softirq, cpu idle | the whole machine's (/proc/stat): cpu system above self system is kernel work done for others, such as sending the frames to an SPI panel |
Measuring a draw
internal/drawcost in the engine's repository is a Lua game that draws N small textured
quads, each an entity and so one draw command of two triangles: 25, then 100, then 200,
400 ticks (20 s) each, and again. Its scenes n25, n100 and n200 draw one count only,
for bench, which checks the counts but not a console's times. On a console:
- Put the folder on the card as
games/drawcost(veduta deployfrom the folder, the card plugged in, or a plain copy). - Start Draw cost from the dashboard, let it run a few minutes, leave it and power off.
- Take its
veduta: perf:lines from the card'svedutaos/kernel.log. Keep those whosecommandsis a count plus the hud's one command (26, 101, 201): a period across two counts has a mean between them. - Fit a straight line through
renderagainstcommands: its slope is the milliseconds a draw costs, its intercept what a frame costs with nothing in it. The parts (submit,readpixels) tell where the draws' cost lands.
Determinism rules
- Randomness only through
ctx.RNG; notime.Now, no environment or file access afterInit. - Never let map iteration order affect state or trace: sort keys first.
- Round every float product that feeds an addition or subtraction with a conversion to its
own type:
y += float32(v * ctx.DT),float32(a*b) + float32(c*d). On linux/arm64, the console, the compiler may fusea*b + cinto one instruction that rounds once, where amd64 rounds twice; about a quarter of all inputs then differ in the last bit and the trace drifts from goldens recorded on a PC. A conversion is a rounding point the compiler may not fuse across, and it costs nothing on amd64. This holds across statements and through inlined calls, sop := a * bfollowed byq := p + cneeds it too.gmathalready rounds inside its own operations (Vec3.Scale,Mul,Dot,Cross,Lerp, matrix and quaternion products), sopos.Add(vel.Scale(ctx.DT))is safe as written.veduta doctorbuilds the game for linux/arm64 and lists every line of the game's code where a fusion happened, and every line of another package that fused after it was inlined into a function of the game, such asgmath/vec.go:105 inlined in demo/game.updatePlayer: there the product to round is the one that function passes in,pos.Add(gmath.V3(float32(a*b), 0, 0)). The text names the first ten places;veduta doctor --jsonlists all of them in the check'ssites. - Use
gmath's trigonometry, nevermath.Sin,math.Exp,math.Powormath.Log: those are not the same on every architecture.math.Sqrt,Abs,Floor,Ceil,TruncandModare exact everywhere. - Same seed + same input ⇒ same trace hash and identical frames on linux/amd64, linux/arm64 and windows/amd64 (the engine's CI runs its goldens on all three).
Trace
One canonical JSON object per tick in trace.jsonl:
{"entities":[{"aabb":{"max":[0.5,1,-0.75],"min":[-0.5,0,-1.75]},"id":2,"kind":"player","material":"hero","model":"hero","name":"player","parent":"","position":[0,0,-1.25],"rotation_deg":[0,180,0],"scale":[1,1,1],"state":{"score":1},"tags":["player"],"visible":true}],"events":[{"event":"gem_collected","gem":"gem_1","score":1}],"tick":75}
Keys are sorted, no whitespace, floats use the shortest form that round-trips (float32
values as float32), non-finite numbers are the strings "NaN", "+Inf", "-Inf".
Entity summaries hold: id, name, kind, position (world), rotation_deg (local
Euler angles, R = Ry·Rx·Rz, pitch in [-90, 90]), scale (local), visible, tags,
model, material, parent (name, "" for none), aabb ({min, max}, world; only
entities with a model or a hitbox, and the hitbox's bounds when one is set) and state
(only when set). Scenario expectation paths address
these keys (position.z, aabb.max.y, state.score). The trace hash is the SHA-256 of
the file's bytes.
Built-in events: scene_load (scene, entities), spawn and despawn (id, name,
kind), collision (a, b, a_id, b_id: two AABBs started overlapping this tick;
touching does not count; two static entities never collide), invariant_violation
(name, detail: an invariant started failing this tick). Game events come from
ctx.Trace.
Invariants
Checked after every tick (including tick 0). A scenario's invariants list is used when
present, else the project's. Built-ins: finite_positions, within_bounds (project
bounds), entity_count_max:N, no_overlap:tagA,tagB. Any other name must be registered
by the game with ctx.Invariant(name, pred). A violation is reported when an invariant
starts failing, not again while it keeps failing.
Headless subcommands
Every game binary accepts -project DIR (default .), -version, and:
game -headless render --scene S --tick T --seed N --camera P --mode M --width W --height H --out F [--bundle] [--input F]
game -headless simulate --scenario F | --scene S --ticks N --seed N [--input F] [--screenshots 0,60] [--invariants a,b] --out DIR
game -headless query --frame F --at x,y | --coverage
game -headless snapshot --scene S --tick T --out F [--input F]
game -headless snapshot --restore F --ticks N [--input F] [--out trace.jsonl]
game -headless describe
game -headless bench --scenario F | --scene S --ticks N --seed N [--input F] [--width W --height H] [--cpus N]
- Each prints one JSON report on stdout. Errors print
{"ok":false,"error":...,"errors":[{file,line,col,msg}]}. - Exit codes: 0 success, 1 error, 2 bad command line, 3 simulate ran and the verdict is
fail. - Camera presets:
scene,top,front,back,left,right,iso,orbit:<deg>, or the name of acameraentity. Render modes:color,wireframe,normals,depth,ids,silhouette,overdraw,uv_checker,collision. simulatewritestrace.jsonl,result.jsonand onesheet.png(screenshots at the requested ticks plus a trajectory tile: seen from the top, or in the XY plane when the scene camera is orthographic and looks along −Z, as a 2D game's does) into the output directory. The verdict isfailwhen an expectation fails or an invariant is violated.- An input file (
--input) is a JSON array of input events or an object with aninputsarray (a scenario file works). benchruns a scenario (or a scene with an input script) the way the player does, without recording a trace, and times each tick's update and its frame rendered with the scene camera at the project's resolution (or--width×--height), the renderer limited to--cpusprocessors. It reportsupdate_ms,render_msandframe_ms(mean,p50,p95,max),budget_ms(1000 /tick_rate),over_budgetticks,slowest_tick, and per framecommands(draw commands),trianglesanddrawn(submitted and rasterized) andculledandreducedentities (mean,max). The times are the machine's that runs it, not the console's;VEDUTA_PERFgives those (see Frame times on a console).snapshotsaves the state at--tickand--restorecontinues from it. A script game's state lives in its interpreter, which no file can hold, so its snapshot is the run itself (scene or world, seed, every tick's input) and restoring plays that run again; the replay must reach the snapshot's trace hash, so a snapshot of a game that has changed since is refused.describelists registered kinds with their state fields, game invariants, whether a state codec is registered, scenes, render modes and camera presets.