Home/GANDER docs/GANDER 0.1 API reference

GANDER 0.1 API reference

The whole engine in one sheet: the shape of a game, Goose for game code, app and input, 2D, canvases, lights, post effects, ocean, particles, decals, models and materials, streaming, animation, audio, faces, high scores, performance, memory, the gander command and recipes.

api.mdllms-full.txt

GANDER is vbgnt's own game engine (CLI gander, Goose namespace gander::): WebAssembly + WebGPU (WebGL2 where the browser has no WebGPU), games written in Goose. Goose compiles ahead of time to C, then to wasm: there is no interpreter, no eval, no hot reload. Everything below is checked by gander check in about a second, so lean on it.

The shape of a game

import gander from "./.gander/gander";     // root file only; .gander is managed by the gander tool

var x: f32 = 0.0;                     // game state lives in globals

fn main() {                           // runs once: config, then load everything
    gander::config("My Game", 1280, 720, 0);
}

export "gander_frame" fn frame(dt: f32) {   // runs every frame; dt in seconds
    x += 60.0 * dt;
    gander::rect(x, 100.0, 40.0, 40.0, gander::RED);
}
  • Immediate mode: draw everything you want visible every frame. Nothing persists between frames except settings (camera, sun, sky, fog, post) and loaded resources.
  • Resources (gander::Texture, Model, Mesh, Material, Sound, Font, Anim, Arms, Face) are small handles. Load once in main(), keep them in globals. A failed load returns handle 0 and logs a warning; drawing with it warns and draws nothing.
  • Other modules: import player; in main.goose loads player.goose beside it; its functions and globals are then visible everywhere (no namespace unless the file declares one). Only main.goose has the import gander from "./.gander/gander"; line. Other files may write import gander; or nothing; the package is visible program-wide once the root declares it. Call it as gander::name everywhere.
  • Vectors are the standard library's float2, float3, float4 (global names, fields x y z w, + - * / elementwise and by a scalar, dot, cross, length, normalize, distance, lerp). Quaternions are float4. Matrices are gander::Mat4 (column major).

Goose for agents: what actually bites

  • Float literals are f64. let a = i * 0.5; is an f64 and will not pass where f32 is wanted. Annotate: let a: f32 = i * 0.5;. Integers widen to floats implicitly; narrowing needs a cast. Float to int: use as! ((t * 10.0) as! i32 truncates toward zero, like C) or cast an exact value (floor(x) as i32, round(x) as i32). A plain as from a float with a fraction aborts in gander build --debug builds ("as conversion changes the value"); colour bytes the same: (c * 255.0) as! u8.
  • Some everyday words are keywords: pad cannot be a field or variable name (it is struct padding). Pick another (margin, gap).
  • as only converts numbers. flag as f32 on a bool is an error; write if flag { 1.0 } else { 0.0 }.
  • if is an expression: let s = if fast { 8.0 } else { 4.0 }; (both arms must have the same type).
  • Mutating array elements needs &: for &e in enemies { e.hp -= 1; }. for e in enemies copies, and writing to the copy is a compile error. for e, i in xs gives the index (an i64).
  • Ranges: for i: i32 in 0..10 { } (half open). Without the type, i is an i64.
  • .len is a property, not a call: xs.len (an i64; xs.len as i32).
  • xs[f()] where f pushes onto xs fails ("index 11 out of bounds (length 11)", reads and writes alike): Goose reads the array's length before it calls the index expression. Split it: let i = f(); let m = xs[i]; (a find-or-load helper that appends: kit_models[kit_by_name("SM_Torch")]).
  • Arrays: var xs: Enemy[>..] = []; is a growable array; xs.push(v) returns a reference to the new element. Fixed: f32[16], literal [0.0; 16]. Remove by rebuilding or keep an alive: bool flag (cheapest).
  • Things that spawn and die all the time (bullets, sparks, pickups) fit a slot_pool, which hands freed slots back out instead of growing: goose import slot_pool; var shots = slot_pool<Shot> {}; let i = shots.alloc_index(Shot { pos: p, alive: true }); // or alloc_ref(v) -> Shot& shots.items[i].alive = false; shots.free(i); // free_ref(s) frees by reference for &s in shots.items { if s.alive { ... } } items keeps freed slots (items.len is the high-water count and the loop visits them), so keep the alive flag and clear it before free.
  • root struct Level { enemies: Enemy[>..] = [], shots: Shot[>..] = [], wave: i32 = 1 } holds several growable arrays in one value (var lv = Level { .. };, global or local, passed as Level&); it cannot be copied, returned or stored in an array. dst.move_from(src) copies between equal-length slices even when they overlap: xs[1..n].move_from(xs[0..n - 1]) shifts a list along by one.
  • Structs: struct E { pos: float3, hp: i32 = 100 }. Literals name fields (E { pos: p }, hp takes its default) or list them all positionally; E { pos: p, .. } zeroes everything not given.
  • Strings are u8 arrays. Literals are const u8[:] slices. Build text with a builder: goose fn hud_line(score: i32, hp: f32) { var s: u8[>..] = []; format(s, "SCORE ", score, " HP ", hp as! i32); gander::text(s, 20.0, 20.0, 28.0, gander::WHITE); } str(a, b, ...) builds a fresh u8[>..]. print(...) writes to the log. std has find, split, trim, parse_int, parse_uint, and split_once(s, sep) (before, after, found: let k, v, ok = split_once(line, "=");), plus ascii::is_digit is_alpha is_alnum hex_value.
  • No closures, no function pointers, no classes, no exceptions, no null by default. Use enums with match for state machines: declare enum Scene { Title, Play, Over }, build values as Scene.Play, and match with bare variant names, one comma after each arm: match scene { Title => { ... }, Play => { ... }, Over => { ... }, }. Variants may carry data (Hit { damage: i32 }, matched as Hit h => ...); a match must cover every variant or end with _ => ....
  • Functions overload by type; untyped parameters make generics.
  • Multiple return values: fn f() -> f32, bool { return 1.0, true; } and let v, ok = f();.
  • const and let globals with literal values are compile-time constants.
  • Every global needs an initial value or a type (var hero: gander::Model; starts as handle 0 until main() assigns it).
  • References cannot outlive what they point at; the compiler says so with the line. Copy values out instead of keeping references in globals.
  • A comparison its type already decides warns (comparison is always true: u32 values are 0 to 4294967295), and one the type cannot hold is an error (b > 300 of a u8). Handles are u32 ids, so tex.id >= 0 means nothing: a handle that was never loaded is tex.id == 0.
  • Math: sin cos tan atan2 sqrt pow exp floor min max clamp abs lerp work on f32 and f64 (std and math come with the gander package), and so do radians degrees is_nan is_inf is_finite (an f32 angle gives an f32 back, no as f32). An integer argument needs a width: radians(90.0), not radians(90). is_finite(v) is the cheap guard before using a physics or division result. Also at both widths: fract smoothstep(e0, e1, x) inverse_lerp(a, b, v) wrap_angle (radians into [-PI, PI)), hypot cbrt exp2 sinh cosh tanh copysign, and the constants PI TAU E INF NAN.
  • Do not write your own helper under a std or math name (wrap_angle, smoothstep, fract, INF...): a same-typed copy makes every call "ambiguous call to wrap_angle", and a global INF is "global name already declared". Delete your copy and use the built-in one, or rename it (SCORE_INF).
  • A function that calls itself, directly or through others (damage -> kill -> damage), is an error ("recursive call cycle through kill, which is not declared recursive fn"): write recursive fn kill(...) on the function the message names.
  • defer runs a statement when its block ends, at every exit (fall-through, break, continue, return), latest first: gander::perf_begin("ai"); defer gander::perf_end("ai"); times the whole function however it returns. Its body cannot itself return, break or continue.
  • Not allowed in gander games: extern declarations (the publish refuses them) and thread_fn (no threads on the web).

Conventions

  • Colors are sRGB 0..1 like CSS: gander::hex(0xff8040), gander::rgb(1.0, 0.5, 0.2), gander::rgba(...), constants WHITE BLACK RED GREEN BLUE YELLOW CLEAR, with_alpha(c, a), mix(a, b, t).
  • 3D: meters, +Y up, -Z forward (glTF). Characters face -Z when yaw = 0.
  • 2D: logical pixels, origin top left, +Y down. The logical height is the config() height; the width follows the window's aspect (so read gander::width() for layout, never assume 1280). With gander::KEEP_ASPECT or gander::PIXEL_ART the size is exactly the config size, scaled as large as the window allows and letterboxed only on the mismatched axis (pixel art stays crisp at any scale; the engine resamples it, never draws it small). gander::PIXEL_ART | gander::EXPAND fills the screen with no bars at all: the height stays the config height and the width follows the aspect, so lay out from gander::width() and let backgrounds run past the play area. Use it for pixel games meant for phones and any game that should fill a browser. gander::EXPAND | gander::KEEP_ASPECT (with or without PIXEL_ART) always shows the whole config box and grows the canvas on the other axis: centre a fixed layout at ((width() - W) / 2, (height() - H) / 2) and draw your own surround (a tiled wall, a bezel, the title) in the spare room. Calling config() again at runtime switches the box, e.g. 640x360 when the window is wider than tall and 360x640 otherwise (leave out the orientation flags).
  • 2D draws in the order you submit them within a layer; gander::layer(z) sorts (higher on top). 2D always draws over 3D.
  • Angles are radians; gander::deg(90.0) converts. Field of view is degrees.

App

config(title, w, h, flags)    flags: PIXEL_ART | EXPAND | NO_MSAA | NO_HDR | KEEP_ASPECT
time() -> f64   dt() -> f32   frame_count() -> i64
width() -> f32  height() -> f32          logical 2D size
is_mobile() -> bool   is_test() -> bool  (true under gander test)
is_capture() -> bool  (true under gander capture and gander test --clean: hide debug text, test HUD)
can_quit() -> bool   false on the web (a page cannot close itself) and iOS (apps never quit
                themselves), true on desktop exports and Android. Show a Quit button only when true
quit()          end the game after this frame (saves written this frame land; Flow's Quit calls it).
                Where can_quit() is false it does nothing (one warning). gander test --native: the run
                ends there, ok, "quit: ... at frame N"
launch_option(name, out&) -> bool   ?name=value on the play URL, --name=value natively; true when
                given (out "" for a bare flag). Demo/attract modes: launch_option("demo", mode).
Loading screen (web): assets/splash.png (else cover.png) behind the progress bar; or a montage:
                assets/splash/montage.json {"title", "subtitle", "seconds": 3.2, "min_seconds": 0,
                "frames": [{"src": "a.jpg", "caption": "..", "fit": "contain", "bg": "#fff"}]} with its
                images beside it: crossfades with a slow pan and zoom until the game is ready (or min_seconds,
                a click skips). Served beside the build, not loaded into memory with the assets. Also
                "warm_frames" (45: frames drawn behind it before it fades) and "watchdog": false (opt out).
                Web games start by themselves once warmed up (no button); a game that plays sound before the
                player has touched anything gets a small "Tap to enable sound" hint. Every web game reloads
                itself on a black screen before it shows, or on a lost GPU after (WebGL2 on the retry).
                Test with gander test / capture --opt demo=deck
log(s)          print to the log (gander test shows it)
fail(s)         stop the game with an error (shows in gander test)
probe(key, f64) probe_text(key, s)       values gander test reports
seed(u64)  rand(lo, hi) -> f32  rand_int(lo, hi) -> i32   (deterministic per seed)
save(key, value)  load(key, out: u8[>..]&)   persistent (localStorage); one setting or a best time
save_begin() save_put(key, v) save_end(slot, label)  save_open(slot) save_get(key, def)
                save games in slots with a slot screen and Flow menus: [GANDER saves](/docs/gander/saves/)
unix_time() -> i64   wall clock seconds
open_url(url)
read_asset(path, out: u8[>..]&) -> bool   a data file under assets/ (level, dialogue, JSON) appended to out

Assets are read by the engine only: load them with texture, model, sound, music, font and the rest, and data files with read_asset. Never os::read_file (or os::file_exists, os::list_dir) on anything under assets/: exported builds ship assets/ sealed in one encrypted pack (made by the platform at export), and those calls find nothing there.

Input

Prefer actions and axes: they merge keyboard, mouse, gamepad and touch.

move_axis() -> float2     WASD / arrows / left stick / touch stick; +y = forward/up, length <= 1
look_axis() -> float2     mouse (while locked or a button is held) / right stick / touch drag;
                          roughly degrees this frame, +x right, +y down
look_axis_source(sources) -> float2   the same from some sources only: 1 mouse, 2 right stick, 4 touch
                          (or them together), to give each its own sensitivity; pad_stick(1) is the raw stick
action_down(name) action_pressed(name) action_released(name) -> bool
bind_key(name, KEY_*)  bind_pad(name, PAD_*)  bind_mouse(name, MOUSE_*)  unbind(name)
touch_controls(flags, "act1,act2", "LABEL1,LABEL2")
                          flags TOUCH_STICK | TOUCH_LOOK; up to 12 buttons, first is the big one;
                          shown only after the first touch
touch_button_place(i, x, y, size)   button i at x, y (screen fractions, 0, 0 top left), size x the standard
                          button; x < 0 back to the default fan (after touch_controls, which resets it)
touch_button_toggle(i, on)  a tap turns the action on until the next tap (aim, crouch);
touch_button_set(i, on)   changes a toggle from the game (a reload ends aiming)

Default actions: left right up down (WASD, arrows, d-pad), jump (space, A), fire (left mouse, J, RT), aim (right mouse, K, LT), interact (E, X), reload (R, X), sprint (shift, left stick click), crouch (C, ctrl, B), pause (escape, P, start), confirm (enter, space, A), back (escape, backspace, B), next (Q, RB), prev (Z, LB). Unknown action names warn.

Raw input:

key_down(KEY_*) key_pressed key_released          KEY_A..KEY_Z, KEY_0..KEY_9, KEY_SPACE, KEY_ENTER,
                                                  KEY_ESCAPE, KEY_TAB, KEY_BACKSPACE, KEY_LEFT/RIGHT/UP/DOWN,
                                                  KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_F1..KEY_F12
mouse() -> float2 (logical)  mouse_delta() -> float2 (pixels)  mouse_wheel() -> f32
mouse_down(MOUSE_LEFT|MOUSE_MIDDLE|MOUSE_RIGHT) mouse_pressed mouse_released
pointer_lock(true)        locks on the next click (browsers need a gesture); pointer_locked()
pad_connected() pad_down(PAD_*) pad_pressed(PAD_*) pad_stick(0 left | 1 right) -> float2
pad_rumble(low, high, seconds)   heavy and light motor 0..1 (a hit: 0.8, 0.4, 0.2); 0 s stops; no-op without a pad or on Safari
touch_count() touch_at(i) -> float3 (x, y logical; z = 1 on its first frame) touch_seen()
text_count() text_at(i) -> u32    characters typed this frame (name entry)

2D

texture(path) -> Texture          PNG / JPG; texture_w(t) texture_h(t)
texture_pixels(w, h, rgba)        from RGBA bytes (u8 array, w*h*4)
sprite(t, Sprite { ... })         see the Sprite fields below
sprite(t, x, y)                   centered at x, y
sprite_rect(t, sx, sy, sw, sh, x, y, w, h, tint)   a source rectangle into a rectangle
nine_slice(t, x, y, w, h, border, tint)            panels
rect(x, y, w, h, c)  rect_outline(x, y, w, h, thick, c)  circle(x, y, r, c)  ring(x, y, r, thick, c)
line(x1, y1, x2, y2, thick, c)
camera2d(x, y, zoom, rot)         world point (x, y) at the screen center for the draws that follow
ui()                              screen space for the draws that follow (HUD)
layer(z)                          sort key for the draws that follow
world_to_screen2d(x, y) screen_to_world2d(x, y) -> float2   with the current camera2d
font(path) -> Font                TTF / OTF; DEFAULT_FONT is built in
text(font, s, x, y, size, c, align) -> width     y is the top; ALIGN_LEFT / CENTER / RIGHT; \n works
text_shadow(...)                  same with a drop shadow (readable over 3D)
text(s, x, y, size, c)            built-in font, left aligned
text_center(s, x, y, size, c)     built-in font, centered, shadowed
text_width(font, s, size) -> f32  text_line_height(font, size) -> f32
text_wrap(font, s, x, y, size, c, align, width) -> height   wrapped into a box width wide, top left (x, y),
                                  aligned inside the box; breaks at spaces, keeps \n, splits a word wider
                                  than the box
text_wrap_height(font, s, size, width) -> f32   the height text_wrap will draw (size the panel first)
canvas_texture(w, h) -> Texture   a texture the 2D API draws into (see Canvases)
canvas_begin(t) / canvas_end()    every 2D call between them draws into t
canvas_clear(t, c)                what canvas_begin clears to (default transparent)
canvas_keep(t, keep)              true: canvas_begin draws over the last picture instead of clearing

A panel sized to its text:

let h = gander::text_wrap_height(gander::DEFAULT_FONT, msg, 20.0, 320.0);
gander::rect(20.0, 20.0, 344.0, h + 24.0, gander::rgba(0.0, 0.0, 0.0, 0.6));
gander::text_wrap(gander::DEFAULT_FONT, msg, 32.0, 32.0, 20.0, gander::WHITE, gander::ALIGN_LEFT, 320.0);

gander::Sprite fields and defaults: x 0, y 0 (where the origin lands), w 0, h 0 (0 = the frame's pixel size), ox 0.5, oy 0.5 (origin as a fraction: center), rot 0 (radians, clockwise), sx 1, sy 1 (scale), r g b a 1 (tint), frame 0, cols 1, rows 1 (sheet grid, row-major frame index), flags 0 (FLIP_X | FLIP_Y).

Canvases: 2D drawn into a texture

A canvas is a texture you draw with the 2D API: a tracker's round CRT, a wrist unit's readout, a camcorder's OSD, a terminal's text, a map. Draw it between canvas_begin(t) and canvas_end() (shapes, sprites, text, wrapped text, clips, camera2d all work; origin top left, width() / height() report the canvas size in between), then use t like any texture: material_texture for a screen in the world, sprite on the HUD, a particle texture.

// main()
trk = gander::canvas_texture(256, 256);
gander::canvas_clear(trk, gander::hex(0x02140a));
trk_glass = gander::material(gander::WHITE, 0.0, 0.3);
gander::material_texture(trk_glass, gander::SLOT_BASE, trk);
gander::material_flags(trk_glass, false, true);          // unlit: the screen shows its own light

// frame(): only while the tracker is on screen
gander::canvas_begin(trk);
gander::ring(128.0, 128.0, 120.0, 2.0, green);
gander::line(128.0, 128.0, 128.0 + sin(a) * 118.0, 128.0 - cos(a) * 118.0, 3.0, green);
gander::text(gander::DEFAULT_FONT, "12 M", 128.0, 212.0, 26.0, green, gander::ALIGN_CENTER);
gander::canvas_end();
gander::draw_mesh(gander::MESH_PLANE, trk_glass, screen_xf);
  • A canvas begun this frame is redrawn before the 3D passes, so the materials that show it see this frame's picture. A canvas you do not begin keeps its last picture and costs nothing: draw static text once, redraw live readouts every frame they change.
  • canvas_begin clears to canvas_clear's colour (transparent by default). With canvas_keep(t, true) it draws over the last picture instead: a phosphor trail is a translucent dark rect over the whole canvas first.
  • For 3D it is used as colour (base or emissive slot): the engine keeps a mipmapped copy with straight alpha, made the first time a material uses it, so a screen stays steady at a distance. Its UVs are the image's (v = 0 at the top), like a loaded image.
  • Several canvases per frame are fine (16 at most). A canvas can show another canvas as a sprite (the one made earlier is drawn first); never itself. It is for display: UI widgets drawn into it do not take input.
  • Cost: one small 2D pass per canvas you began, plus a copy and its mip levels when a material shows it (256 x 256: well under 0.1 ms of GPU).
camera(eye, target, fov_deg)       camera_xform(world: Mat4, fov_deg) (looks down its -Z)
camera_ortho(eye, target, half_height)   camera_clip(near, far)  (default 0.05, 1000)
camera_pos() camera_forward() camera_right() -> float3
sun(dir, color, intensity, shadows: bool)   dir points FROM the sun (down = negative y); ~3 = daylight
shadow_distance(m)                  default 60
sky(zenith, horizon, ground, intensity)    procedural; also lights the scene (ambient + reflections)
sky_hdri(path, intensity, rotation)        equirect .hdr (Poly Haven 2K) or JPG
sky_off(clear_color)                no sky, flat background
ambient(k)                          scale the sky light (default 1)
fog(color, density, height_falloff, start)   density 0.01 haze .. 0.1 thick; 0 = off
fog_light(sun, lamps, shafts)       light in the air, each 0 off .. 2 (below)
fog_light_count(n)                  lamps (nearest first) given a halo: 8 by default, up to 64 (each costs fogged pixels)
fog_volume(center, size, color, density, lamps) -> i32   a soft blob of haze (roof smoke, mist in a hollow): no edges,
                                    densest at the centre (density per metre there); fog_volume_move / _remove / fog_volumes_clear;
                                    up to 128 placed, the 16 in sight that fill most of the view drawn (they fade as they swap)
fog_volume_shape(id, size, density)   resize one (smoke spreading); fog_volume_budget(n) how many draw at once (1..16; phones 8)
fog_volume_scatter(id, extinction, albedo, phase)   how it hides, glows and leans (steam, soot, murk; below)
fog_scatter(extinction, albedo, phase)   the same for fog() and the lamp halos in it (defaults 1, 1, 0)
fog_zone(center, size, FogZone { .. }, margin) -> i32   a box of different air while the camera is in it (a dark zone);
                                    fog_zone_remove(id) fog_zones_clear()
post(exposure, bloom, vignette)     defaults 1.0, 0.6, 0.25
post_fx(PostFx { .. })              grading, grain, blur, damage edge, night vision / CRT / thermal, auto exposure (below)
post_zone(center, size, PostFx, margin) -> i32   a box with its own grade while the camera is in it; post_zone_remove(id) post_zones_clear()
light(pos, color, intensity, range)          point light, this frame only (clustered: below)
spot(pos, dir, color, intensity, range, angle_deg)
light_shadowed(...)  spot_shadowed(...)       the same, casting shadows (torches, lamps, flashlights)
tube_light(a, b, color, intensity, range)    a strip light from a to b (station tubes, neon)
light_full(Light { .. })                     any of them with tube length, source radius, fog strength, shadows (below)
world_to_screen(p) -> float3        logical x, y; z < 0 behind the camera
time_scale(s)                       1 normal, 0.2 slow motion, 0 frozen (hit stop); scales frame's dt, animation,
                                    particles, agents (not audio)
camera_arm(pivot, yaw, pitch, dist, radius, ignore) -> float3   third-person eye pulled in front of walls
shot_step(pos&, vel&, dt, gravity, drag, mask, ignore) -> RayHit  a bullet / arrow that cannot skip thin walls
screen_ray(x, y) -> Ray             Ray { origin, dir } through a logical point (mouse picking)
debug_line(a, b, color)
viewmodel_fov(deg)                  FOV of the first-person pass (default 55)
pro_graphics(on: bool)  is_pro() -> bool  pro_look(ProLook { .. })   Pro graphics in desktop exports (below)

Many lights

Lights are clustered: the view is cut into cells (16 x 9 screen tiles by 24 depth slices, 12 x 8 x 16 on phones) and each cell lists the lights whose reach touches it, so a pixel pays for the lights at its spot, not for every light in view. A room with a dozen station tubes, door spills, CRT glows and two beacons is fine. Per frame: up to 256 lights submitted; the GPU takes the 32 nearest whose reach is in view; each spot of the screen takes up to 32 of them (phones 16), nearest first. With 4 or fewer in view every pixel takes them all (cheaper). Keep range honest: it is what decides which cells a light lands in (a range of 40 on a desk lamp lights the whole grid).

gander::tube_light(gander::v3(-0.6, 2.9, z), gander::v3(0.6, 2.9, z), gander::hex(0xdff0ff), 4.0, 4.5);
// a beacon: two rotating 18 degree beams that glow strongly in the air
gander::light_full(gander::Light { pos: p, color: gander::hex(0xff3020), intensity: 80.0, range: 14.0,
                                   dir: gander::v3(cos(a), -0.2, sin(a)), angle: 18.0, fog: 1.6 });

Light fields: pos, color, intensity, range, dir and angle (a spot when angle > 0, full cone in degrees), length and axis (a tube centred on pos: lit from its nearest point, its highlight a streak in glossy floors), radius (source size in metres: softer, wider highlights; a bulb 0.05, a globe 0.2), fog (how strongly it lights the air: halos and fog volumes; 0 none, 1 default), shadows. light, spot, tube_light and the shadowed calls are shorthands for it.

Shadowed lamps

light_shadowed / spot_shadowed take the same arguments as light / spot and cast real shadows: a torch on a pillar darkens the wall behind it, a flashlight throws the table's shadow. Shadow maps go to the shadowed lights nearest the camera whose range reaches the view: a spot costs 1 tile, a point light 6 (a cube), 64 tiles a frame (past 16 they get smaller, so a hall of 8 torches is fine). Use plain light for fill and for small glows (muzzle flashes, embers, fire cores): it is much cheaper. A spot is the cheapest shadowed light; prefer it for flashlights, ceiling lamps and headlights. Shadowed lights are clustered like the rest; the shadow tiles go to the nearest shadowed lights whatever else is in view, so 40 plain tubes beside 8 shadowed torches keep the torches' kept tiles. A tube with shadows casts from its centre.

A spot can carry a cookie: an image across its cone that shapes the beam (rgb multiplies the light). spot_cookie(t) applies to the next spot / spot_shadowed this frame; cookie_torch() is a flashlight's beam (hot centre, dark ring, brighter band, soft rim). The image's centre is the beam's axis, its top toward world up, its edge the cone's edge. Up to 16 cookie images a frame; a texture is copied into the cookie atlas once.

let torch = gander::cookie_torch();          // once
gander::spot_cookie(torch);                  // every frame, before the spot
gander::spot_shadowed(hand, aim, gander::hex(0xfff2d0), 60.0, 14.0, 40.0);

Tiles are kept from frame to frame. Each tile's still geometry (walls, furniture, props) is drawn once and kept until its light moves or a prop in it does; characters (anything skinned), cloth and anything rigid that moved since the last frame (a sword in a hand, a swinging door) are drawn over that kept depth each frame, which costs a copy and the characters, not the hall. So a hall of still torches with fighters moving through it pays for its walls once. Keep shadowed lights still (a flickering torch moves its intensity, not its position) to get this; shadow_tiles_drawn() says how many tiles' still geometry was redrawn last frame (0 once settled; in perf checks), shadow_cache(false) redraws everything every frame.

What a light's tiles hold depends on that light, not on the camera: only draws whose bounds reach the light count, detail levels follow the distance to the light, and instanced copies are judged one by one. So the player walking or turning does not redraw them, a light keeps its tiles while it stays shadowed (another spot coming into view or leaving does not move them, and a light that leaves and comes back finds its tiles kept), and draw_model_instanced_lod copies cast into a lamp's tiles whatever the camera culled (out of sight, past max_dist, impostors). What does redraw a tile is a change inside the light's reach: a prop the game itself stops or starts drawing there (culling props by distance from the camera near a shadowed fire refills the fire's tiles every time one switches). For scenery near lamps, draw it with draw_model_instanced_lod, whose own max_dist culls for the camera only.

Light in the air

gander::fog(gander::hex(0xc9b8a0), 0.008, 0.05, 10.0);
gander::fog_light(0.8, 0.0, 1.0);    // dusty sunset: haze glows toward the sun, shafts through the trees
gander::fog_light(0.0, 1.0, 0.0);    // crypt, foggy street at night: halos around every lamp and torch

sun: fog lit from behind glows toward the sun (needs fog). lamps: every light gets a halo in the air, fog or not, scaled by its Light.fog (each screen tile takes the 32 nearest lamps whose reach crosses it, phones 12); a tube's halo follows the tube; shadowed lamps do not glow through walls (each costs three shadow taps a pixel, so give dim shadowed lamps fog: 0). shafts: rays of sun through gaps in leaves, windows, ridges and clouds when the sun is in or near the view (needs the sky; skipped on GPUs without HDR targets). Values around 0.5 to 1 read as natural; 2 is heavy. All cheap enough for phones.

How the air scatters: fog_scatter(extinction, albedo, phase) for fog() and the halos, fog_volume_scatter(id, ...) for one volume. extinction scales how much it hides behind it (1 as set, 2 smoke, 0.3 a glow that keeps the background), albedo how much light it gives back (1 white steam, 0.2 soot), phase -0.9..0.9 how lamp light leans in it (0 even, 0.6 a lamp in front of you glows much more than one beside you). A fog volume's density falls off from its centre (Unreal's radial extinction): a steam jet is a tall thin volume with a bright forward-scattering phase.

let jet = gander::fog_volume(gander::v3(1.4, 1.2, -15.0), gander::v3(1.0, 2.8, 1.0), gander::hex(0xb8c4d0), 2.5, 1.5);
gander::fog_volume_scatter(jet, 0.6, 0.95, 0.6);
// a dark zone: thick murk while the camera is inside, the torch barely lighting the air
gander::fog_zone(gander::v3(0.0, 1.5, -56.0), gander::v3(4.4, 3.2, 12.0),
    gander::FogZone { density: 0.09, extinction: 1.5, albedo: 0.3, phase: 0.2, lamps: 0.12 }, 4.0);

fog_zone blends in over margin metres while the camera is in the box (the smallest zone holding it wins, like post_zone): it replaces fog_scatter's values, fog()'s density (when density >= 0) and colour (when color alpha > 0), and multiplies every light's fog by lamps. Up to 16 zones.

Pro graphics (desktop exports)

Native desktop builds (macOS, Windows, Linux exports and gander test --native on a Mac) render Pro graphics by default; the web and phone builds keep the standard look, so the game itself needs no changes. Pro adds:

  • temporal anti-aliasing instead of MSAA: no shimmer on thin poles, wires, fences and foliage edges, also in motion
  • ambient occlusion: corners, contact points and the ground under objects darken (sky light only, so sunlit areas stay bright)
  • light bounced off nearby surfaces: a sunlit wall warms the floor next to it, torches and glowing signs tint what is around them
  • reflections of the scene itself in glossy floors, water and metal (surfaces with roughness under about 0.5: material(color, metallic, 0.1) is a polished floor)
  • fog_light's sun and shafts as real light through the fog, shadowed by trees, slats, windows and ridges (needs fog for the haze; shafts alone light the clear air a little)

The ambient occlusion and bounced light come from the depth the frame already drew (no second pass over the scene's geometry, characters included) and reach the next frame's lighting: contact shadows under something moving trail it by one frame.

gander::pro_look(gander::ProLook { ao: 1.3, reflections: 0.6 });   // darker corners, softer reflections
if !settings.high { gander::pro_graphics(false); }                // a Graphics: High / Standard option

ProLook fields: ao 0..2 (1), ao_radius meters (1), gi 0..2 (1), reflections 0..1 (1), sharpen 0..1 after anti-aliasing (0.25). is_pro() says whether this build renders Pro (false on the web and phones). Check it with gander test --gpu --opt pro=1 (forces Pro in a web test on a real GPU; on the software GPU Pro is far too slow to judge) and the standard look without it. Shots of a Pro test render the 8 frames before each shot so the anti-aliasing settles. Reflections only show what is on screen: a reflection fades at the screen edge and falls back to the sky's. The built-in surface shaders (material_hologram, _refraction, _crt), material_shadow_only and spot cookies draw the same in Pro; refraction reads the scene before anti-aliasing, so it settles with the rest of the frame.

Post effects

post_fx(PostFx { .. }) grades the image and adds screen effects on top of post(). Settings stay until the next call; post_fx(PostFx {}) puts them back.

gander::post_fx(gander::PostFx { saturation: 0.7, contrast: 1.15, warmth: -0.5, grain: 0.2 });   // cold horror grade
gander::post_fx(gander::PostFx { auto_exposure: true });          // eyes adapt: dark rooms brighten, daylight dazzles
gander::post_fx(gander::PostFx { mode: gander::POST_NIGHT_VISION });   // also POST_CRT, POST_THERMAL
// taking a hit: red edges that fade (call every frame while it fades)
gander::post_fx(gander::PostFx { edge: gander::Color { 0.6, 0.0, 0.0, hurt }, blur: hurt * 0.3, aberration: hurt });
Field Default What it does
saturation / contrast 1 / 1 0 is black and white; 1.2 contrast is punchy
warmth 0 -1 cold blue .. 1 warm orange
tint white multiplies the image (sepia, red alarm light)
grain 0 film grain, 0.15 subtle .. 0.5 found footage
aberration 0 color fringes toward the edges (impacts, cheap lenses)
blur 0 whole-screen blur (stunned, pause menu backdrop)
edge dark red, alpha 0 colored vignette; alpha is the strength (damage, poison in green)
wobble 0 wavy screen (drunk, underwater, heat haze)
mode / mode_amount POST_NORMAL / 1 night vision goggles, CRT tube (scanlines, curved glass), thermal camera; fade a mode in with mode_amount
auto_exposure false eye adaptation; exposure_min / exposure_max (0.25 / 4, multiples of post exposure) and adapt_speed (1.5). A post_zone with auto exposure brings its own range (a bunker adapts brighter than the field)
exposure 1 multiplies post exposure (0.7 a darker zone)
focus / aperture 0 / 0.6 depth of field: the distance (m) kept sharp, 0 off; how strongly nearer and farther things blur. Set focus to the speaker's distance in a dialogue shot. Anti-aliasing (MSAA) pauses while it is on, so turn it off again after the shot. Its pipelines are built on the first frame, so turning it on does not stall
f_stop 0 > 0: the aperture from a lens f-number instead of aperture (f/1.4 is 1, f/2.8 0.5, f/5.6 0.25)
vignette / bloom -1 / -1 below 0: post()'s; 0 or more: this value (a zone with a heavier vignette, a render view's own bloom)
double_vision / double_offset 0 / (0.03, 0.008) a second, offset copy of the picture mixed in (0..1): sickness, concussion. The offset is in screen fractions (+x right, +y up); animate it (0.055 k, 0.012 k sin(t))
refract_texture / refract_strength / refract_mask none / 0 / false bends the picture through a texture stretched over the screen: droplets or frost on a visor. A normal map (rg the direction, alpha the amount), or with refract_mask a mask whose alpha's slope bends it (paint droplets as soft blobs). 1 is droplets, 4 heavy. A canvas works here too (animated rain)
tear 0 VHS tape tearing 0..1: bands of rows jump sideways for a moment, and a tracking band rolls up the picture with snow in it (a camcorder catching, a CRT going bad)
post_mode_mask(x, y, radius, soft)   the mode only inside a circle on screen (logical 2D): a thermal optic's
                                     glass; radius 0 = the whole screen again
night_vision_style(color, tubes)     the phosphor (default green; white phosphor ~ 0.8, 0.9, 1) and the goggle:
                                     0 one round view, 1 two tubes, 2 four (two dimmer outer tubes)

post_zone(center, size, PostFx { .. }, margin) gives a box its own grade (a cold courtyard, a smoky cellar, a lamp-lit chapel). While the camera is inside, its grade replaces post_fx's, blended in over margin meters outside the box; the smallest zone holding the camera wins. Grade fields, exposure, vignette / bloom (when the zone sets them), double_vision, tear and the refraction strength blend (a zone's refract_texture replaces post_fx's while you are in it); mode and auto exposure stay post_fx's. Make zones once (in main), up to 16.

gander::post_zone(gander::v3(0.0, 4.0, 30.0), gander::v3(40.0, 12.0, 30.0),
                  gander::PostFx { warmth: -0.35, saturation: 0.85, exposure: 0.75 }, 3.0);   // the courtyard

Phones run all of it. Auto exposure needs HDR targets (every desktop and recent phone); without them it stays off.

Ocean

ocean(Ocean { .. })                 the open sea to the horizon; settings stay until the next call
ocean_off()
ocean_height(x, z) -> f32           the water surface at world x, z this frame (what is on screen)
ocean_normal(x, z) -> float3
ocean_foam(pos, radius, amount)     paint foam (wakes, hull wash, splashes); it drifts and fades
ocean_foam_area(centre, size)       where painted foam is kept (default: 512 m around the camera)
ocean_foam_fade(seconds)            default 7; the pale churned water under it lasts 4x longer
ocean_time() -> f64                 the waves' clock, seconds (online: the host sends it)
ocean_sync(t)                       online clients: ease the waves to the host's clock

Never build your own sea mesh, scroll a wave texture or lay foam decals: ocean is a real moving sea drawn on the GPU. Twelve Gerstner waves (long swell along the wind, shorter chop crossing it, each travelling at its real speed), sharp crests, sky reflection, sun glint, light glowing through thin crests, whitecaps on a rough sea, small ripples, and distance handled for you (no glitter far out). It reaches the camera's far clip, so for a sea to the horizon set camera_clip(0.1, 12000.0) and a light haze with fog.

// main() or whenever the weather changes (call every frame to blend it)
gander::ocean(gander::Ocean {
    strength: 0.5,          // 0 glassy calm, 0.4 fresh breeze, 0.7 rough, 1 gale (whitecaps from ~0.3)
    wind: 0.3,              // the way the waves travel, as a yaw (0 = toward -Z)
    flow_z: 12.0,           // the water drifts +Z at 12 m/s: a ship that stays at the origin steams toward -Z
});

Ocean also takes level, wavelength (the longest swell in metres; 0 picks it from strength), deep (the water's colour), crest (the colour of light through the waves), foam (whitecaps, 0..1), flow_x and detail (ripples). Every setting can change at any time, wind and wavelength included: a wind shift or a new sea state turns the waves over a few seconds (each wave fades out and comes back on the new heading), never in a jump. Blend strength and the colours yourself for a gradual front; for wind just set the new value.

Floating and hits use the same waves as the picture:

// a boat or crate riding the swell: height at the centre, tilt from the normal
let y = gander::ocean_height(p.x, p.z);
let n = gander::ocean_normal(p.x, p.z);
// a long ship: sample bow, stern and both sides, pitch and roll from the differences
// (keep it gentle on a big ship: average a few metres and scale the angles down)
// a bullet or a falling jet: below gander::ocean_height(x, z) means it hit the water

Wakes and splashes are foam you paint every frame; the map keeps it, drifts it with flow and fades it, so a few calls per frame make a long trail. Centre the map on the ship (ocean_foam_area(ship_pos, 768.0) once, or each frame if it moves through the world).

// every frame, for a ship at the origin heading -Z (flow_z = speed)
gander::ocean_foam(v3(gander::rand(-6.0, 6.0), 0.0, stern_z + 4.0), 9.0, 0.5);    // churned water behind the stern
for s: i32 in 0..2 {
    let sx: f32 = if s == 0 { -1.0 } else { 1.0 };
    gander::ocean_foam(v3(sx * (beam + 1.0), 0.0, gander::rand(bow_z, stern_z)), 3.5, 0.25);   // hull wash
    gander::ocean_foam(v3(sx * gander::rand(beam, beam + 5.0), 0.0, bow_z + gander::rand(0.0, 12.0)), 4.0, 0.35); // bow wave
}
// a shell, bomb or jet hitting the water: a burst once (with your spray particles)
gander::ocean_foam(hit, 6.0, 1.0);

Painted foam is white where amount is high and breaks into lace as it fades; the paler, turquoise churned water under it lingers. Amounts add up and cap, so painting the same spot every frame is fine.

Online, every player draws the same waves only if their clocks agree. The host puts ocean_time() in its snapshots and clients pass it on; up to a second off, the waves run slightly fast or slow until they agree, so the sea never stutters:

// host, in the snapshot it sends
gander::net_put_f32(buf, gander::ocean_time() as f32);
// client, reading it
gander::ocean_sync(gander::net_read_f32(data, r) as f64);

Cost: one draw of about 43k triangles (19k on phones). The foam map is faded and uploaded only where it holds foam (at most 128 KB), every frame on desktop and at 30 Hz on phones; it shows as ocean in gander test --perf. The ocean is drawn in render views too (a scope sees the sea).

Particles

Smoke, fire, sparks, dust, steam, rain, snow, blood, debris, muzzle flashes, shockwaves. Make each system once in main() from a description, then emit into it; the engine moves, lights and draws what is alive. Never build effects from cubes or spheres moved by hand.

var fire: gander::Fx;
var smoke: gander::Fx;
var sparks: gander::Fx;

fn main() {
    fire = gander::particles(gander::fx_fire());
    smoke = gander::particles(gander::fx_smoke());
    var s = gander::fx_sparks();
    s.floor_y = 0.0;                                   // they bounce on the floor
    sparks = gander::particles(s);
}

export "gander_frame" fn frame(dt: f32) {
    gander::emit_rate(fire, campfire, gander::v3(0.0, 1.0, 0.0), 80.0);            // every frame: a steady flow
    gander::emit_rate(smoke, campfire + gander::v3(0.0, 1.0, 0.0), gander::v3(0.0, 1.0, 0.0), 6.0);
    gander::light(campfire + gander::v3(0.0, 0.8, 0.0), gander::hex(0xff8a3a), 25.0, 10.0);   // fire needs its light
    if hit { gander::emit_dir(sparks, hit_point, hit_normal, 30); }               // a burst at an event
}
particles(Particles) -> Fx          a system (make it once); particles_set(fx, p) changes it
emit(fx, pos, n)                    burst upward      emit_dir(fx, pos, dir, n)   burst along dir
emit_rate(fx, pos, dir, per_second) call every frame it flows (any frame rate)
emit_moving(fx, pos, dir, vel, n)   burst that also carries vel (exhaust of a moving car)
particles_clear(fx)  particles_count(fx) -> i32  particles_wind(float3)   (carries systems with wind > 0)
emit_from_model(fx, a, xf, n)       n points on an animated model's posed skin (drawn at xf), each along the
                                    surface normal: a body crumbling to ash, sparks off a burning creature
emit_from_mesh_model(fx, model, xf, n)   the same for a model drawn with draw_model (its rest pose)
emit_attached(fx, a, bone, offset, dir, per_second) -> i32   an emitter riding a bone: emits every frame
                                    the animator is drawn, no game code (offset / dir in the bone's frame, metres)
emit_attached_rate(id, per_second)  change it (0 pauses)      emit_detach(id)   stop it

Effects on a body. emit_attached once in main() (or when the effect starts) instead of reading a bone every frame: breath at the head, smoke off a censer's lid, frost at a gargoyle's mouth. It follows the bone, spreads a frame's births along the bone's path (a swung censer leaves a trail), pauses while the animator is not drawn and stops by itself when the model is freed; 64 at once, emit_detach the ones you are done with. A body turning to ash is anim_dissolve (the body burns away from the top down with a glowing edge, its shadow too) plus emit_from_model each frame while it burns: the points come from what is left of the body, mostly its burning edge, so the ash leaves in the body's shape. material_dissolve does the same for a prop (every draw of that material).

breath = gander::emit_attached(frost, gargoyle, "Head", gander::v3(0.0, 0.05, 0.12), gander::v3(0.0, -0.3, 1.0), 40.0);
// a creature dying: 0.6 s after the death clip starts, burn it over 1.6 s
let burn = clamp((t - died_at - 0.6) / 1.6, 0.0, 1.0);
gander::anim_dissolve(c.anim, burn, gander::hex(0xff7a2a));      // edge colour; alpha 0 = no glow
if burn > 0.0 && burn < 1.0 { gander::emit_from_model(ash, c.anim, c.xf, 40); gander::emit_from_model(embers, c.anim, c.xf, 6); }
if burn < 1.0 { gander::draw_animated(c.anim, c.xf); }          // gone at 1: stop drawing it

Presets (return a Particles to emit as is or change): fx_fire fx_smoke fx_embers fx_sparks fx_muzzle fx_dust fx_steam fx_blood fx_debris fx_rain fx_snow fx_shockwave. Combine them: an explosion is fx_muzzle (flash, scaled up) + fx_debris + fx_dust + fx_shockwave + a short bright light; a campfire is fx_fire + fx_embers + fx_smoke + a flickering light.

Particles fields: look (FX_SOFT FX_SMOKE FX_SPARK FX_FLAME FX_DROP FX_CHUNK FX_RING FX_STAR) or your own texture (a flipbook sheet with cols / rows plays over each particle's life), additive (glows) or alpha blended, lit (alpha particles take the scene's sun, shadows and lamps), life, speed, spread (0 jet .. 1 hemisphere .. 2 all ways), radius (emit from a ball), gravity (negative rises), drag, wind, size / size_end, color / color_end (alpha 0 fades out), glow (above 1 blooms), spin, stretch (streaks along the velocity: sparks, rain), bounce + floor_y, flat (lies flat: ripples, ground rings), max, and *_jitter for variation.

  • Rain and snow: emit around the camera each frame (emit_rate(rain, camera_pos() + v3(0, 8, 0), v3(0, -1, 0), 2500.0)).
  • Budget: about 24,000 live particles in total on desktop, 8,000 on phones; emits past a system's max or the total are dropped. Big soft puffs cost fill rate: prefer fewer, larger smoke particles.
  • Particles show in the main view only (not in render views), cast no shadows, and don't collide with anything but their floor_y.
  • emit_from_model costs per point, not per vertex (three corners skinned on the CPU); blendshapes are not applied to the points. anim_dissolve burns the animator's skinned meshes; a rigid part of the same model (a helm node) needs material_dissolve on its material.

Decals

Marks painted onto whatever is there: bullet holes, blood, scorch, cracks, stains, paint, footprints. Put one where a raycast hit, facing out along the hit normal; it wraps over corners, steps and uneven terrain. Never fake marks with thin quads or flattened cubes (they float, z-fight and hang off edges).

let hit = gander::raycast(eye, dir, 100.0);   // physics (or raycast_model, terrain_raycast)
if hit.dist >= 0.0 {
    gander::decal(gander::Decal { kind: gander::DECAL_BULLET, pos: hit.point, normal: hit.normal, size: 0.15 });
}
// a wet blood pool that dries away after a minute
gander::decal(gander::Decal { kind: gander::DECAL_BLOOD, pos: p, size: 1.4, rough: 0.15, life: 60.0 });
// paint in the team color; your own image as a decal kind
gander::decal(gander::Decal { kind: gander::DECAL_SPLAT, pos: p, normal: n, size: 1.0, color: gander::hex(0x3fd0ff) });
let paw = gander::decal_image("assets/decals/paw.png");
decal(Decal) -> i32 id    decal_remove(id)    decals_clear()    decal_count() -> i32
decal_image(path) -> i32  a PNG (with alpha, square) as a new kind
decal_image_cell(path, cols, rows) -> i32   a sheet of cols x rows images as one kind (a stencil atlas);
                          Decal.cell picks one, row by row from the top left

Up to 64 kinds of your own. The atlas grows as kinds arrive (256 px slots, 128 on phones; 8 x 4 to start, up to 8 x 16); a sheet takes up to 4 x 4 slots, so an 8 x 8 stencil sheet gets 128 px cells (64 on phones).

Kinds: DECAL_BULLET DECAL_BLOOD DECAL_SCORCH DECAL_CRACK DECAL_STAIN DECAL_SPLAT (white: tint with color) DECAL_FOOTPRINT (toes toward the image's top: set angle to the walk direction with random_angle: false) DECAL_DOT (a soft blot: black for a fake contact shadow under a prop). Decal fields: pos, normal, size (meters), aspect (width / height), depth (reach through the surface, default half the size), angle / random_angle, color (tint, alpha = opacity), rough (roughness under it, -1 keeps the surface's), life (seconds, fades over the last one; 0 stays), cell (the cell of a decal_image_cell sheet).

  • Decals stay where they were put: they do not follow moving objects, characters or skinned meshes. They show on instanced copies (draw_model_instanced, _lod, draw_mesh_instanced): wall pieces can be instanced and still take grime.
  • The newest 256 stay (128 on phones); older ones are replaced. The 64 nearest in view are drawn. A single draw takes the 16 nearest that touch its bounds; instanced copies, and a draw (or a run of the same model) touching more than 16, take them per pixel from the light clusters, up to 16 at any spot; terrain the nearest 16 (8 on phones).

3D: meshes, models, materials

MESH_CUBE (1 m) MESH_SPHERE (d 1) MESH_PLANE (1x1, +Y up) MESH_CYLINDER (r 0.5, h 1) MESH_CONE
DEFAULT_MATERIAL
material(color, metallic, roughness) -> Material      alpha < 1 makes it blended
material_color(m, c)  material_emissive(m, c, strength)  material_uv_scale(m, s)
material_pbr(m, roughness, metallic)   -1 keeps one; with a metal-rough texture they multiply it (as glTF)
material_world_uv(m, metres_per_repeat)   texture from the world position, box-mapped per face: a cube stretched
                                  into a wall or a step tiles evenly instead of stretching (still geometry; 0 off)
material_texture(m, SLOT_BASE|SLOT_NORMAL|SLOT_METAL_ROUGH|SLOT_EMISSIVE|SLOT_OCCLUSION, texture)
material_alpha(m, ALPHA_OPAQUE|ALPHA_CUTOUT|ALPHA_BLEND, cutoff)   material_flags(m, double_sided, unlit)
material_shadow(m, casts)         false: draws with it never enter a shadow pass (eyes, teeth, lashes, hair cards)
material_shadow_only(m, true)     the inverse: casts shadows (sun and lamps) but is never drawn in colour (the hidden
                                  head and arms of a first-person body, the solid stand-in of an invisible creature)
material_vertex_tint(m, false)    vertex colour is data (wear, grime, a mask), never a tint: kit meshes whose screens,
                                  images and glows came out black (with or without vertex layers)
material_texture_get(m, slot) -> Texture   what a slot holds, to put back with material_texture after swapping it
                                  (the game's own handle if it set one, else a handle for the GLB's texture; Texture
                                  { 0 } empty). The GLB's texture is the model's: model_free ends it
material_macro(m, lo_color, hi_color, scale)   large-scale tint in world space (multipliers; ~1/scale m patches): a wall
                                  that is not one flat colour, independent of the vertex data; scale 0 off
material_dissolve(m, amount, edge_color)   burn every draw of the material away: 0 whole .. 1 gone, a noisy
                                  front from the top down, glowing edge (alpha scales the glow); shadows too
material_vertex_layer(m, channel 0..3, color, roughness, metallic, noise, noise_scale)
                                  a vertex colour channel as data: soot (R), worn edges (G), frost or damp (A) blend the
                                  surface toward color (alpha = strength) and roughness / metallic (-1 keeps them);
                                  noise 0..1 breaks it into patches ~1 / noise_scale m across. Bake the channels in
                                  Blender (vertex paint, ambient occlusion, dirt) and keep one material for the kit
material_window(m, strength)   glass that shows the sky through it by view direction (it moves as you walk past);
                                  tinted by the colour / base texture, base alpha 0 = lead or frame (dark metal);
                                  an SLOT_EMISSIVE texture is the view instead (an equirect night sky, a vista)
draw_mesh(mesh, material, xf)     draw_mesh_instanced(mesh, material, xfs: Mat4 array)  (many copies, one draw)
mesh(positions, normals, uvs, indices) -> Mesh   your own geometry: f32 arrays (xyz, xyz, uv), u32 indices;
                                  pass an empty array for normals / uvs to have them computed / zeroed
model(path) -> Model              GLB / glTF (Meshy, Forge, Blender); texture_limit(px) before loading (2048; it also
                                  caps texture() images on the GPU: texture_w / texture_h keep the file's size)
draw_model(model, xf)
draw_model_instanced(model, xfs: Mat4 array)   many copies, one draw per part (feast props, a forest); not culled
                                  one by one: batch by room or area and skip batches out of sight
draw_model_instanced_lod(model, xfs, max_dist, impostor_from)   the same with each copy judged on its own
                                  (scenery in the thousands: prop fields, tree lines, a horizon): none past max_dist
                                  (0 no limit), impostor cards past impostor_from (0 never), detail level by size on
                                  screen, copies out of sight only in shadows within the shadow distance. Culls against
                                  the camera set so far (call camera() first). 4,000 trees: 24 M triangles -> 0.6 M.
                                  Shadowed lamps see every copy in their reach, whatever the camera culled
draw_impostors(model, xfs, fade)  many impostor cards in one call
model_part(m, "Helm") -> i32      a part (a node with a mesh) by name, for masks
draw_model_parts(m, xf, mask) / draw_animated_parts(a, xf, mask)   only the parts whose bit is set:
                                  one rig with every helm, hood and cap, each raider showing his own
model_bounds_min(m) model_bounds_max(m) -> float3      rest pose, model space
Baked lightmaps: a GLB node whose extras say {"lightmap": "lm_house.png", "lightmap_range": 8} draws its
                                  static meshes with that RGBM PNG (beside the GLB) read on TEXCOORD_1 as their
                                  sun + sky + bounce light and shadows (Blender: Cycles DIFFUSE bake, direct +
                                  indirect, no colour). Sun and sky still add their specular; lamps you add light
                                  it on top. Ship the PNG with the GLB. Skinned meshes ignore it.
model_lightmap_gain(m, k)         brighten or darken the baked light (1 as baked), no re-bake
                                  Also in the extras: "lightmap_sun": 1 (the bake left the sun's direct light out: call
                                  sun() with the bake's direction and strength and it is drawn live with crisp shadows;
                                  turn glass's shadow off, material_shadow(m, false), or no sun gets in) and "probes":
                                  "probes.png" + "probe_boxes" (per room: box lo, hi, capture point) for reflections of
                                  the room on floors, counters and metal (walkunbuilt pipeline/bake.py writes all three).
model_anim_count(m)  model_anim_name(m, i, out)  model_anim_length(m, name)
model_material(m, i) -> Material  recolor a model's i-th material; model_material_count(m) how many
model_material_named(m, "BagTan") -> Material   by its glTF name ("BagTan.001" matches too)
model_material_name(m, i, out) -> bool   appends material i's glTF name; false past the end
model_instance(m) -> Model        the same meshes, detail levels and textures with materials of its own (below)
model_nodes(m, out)  model_morphs(m, out)   newline-separated names (debugging)
raycast_model(m, xf, origin, dir, max) -> Hit    raycast_mesh(mesh, xf, origin, dir, max) -> Hit
                                  Hit { dist (< 0: miss), nx, ny, nz }

Instances: one model, materials per copy

model_instance(m) is the same model with its own copies of the materials: paint each kit section its own colour, make one lamp's tube flicker alone, show a crew body as a hologram while the same crew stays normal elsewhere. It shares the meshes, detail levels, skins, animations and textures (no second load, no second copy in memory); model_material / model_material_named on the instance return its copies. Everything that takes a Model takes it: draw_model, _instanced, _instanced_lod, _parts, animator, impostors, terrain scatter, groups, shadows and lamp tiles. One instance drawn many times still batches into one draw; two instances never batch with each other (their materials differ), so give each look one instance and reuse it rather than one instance per copy.

let kit = gander::model("assets/models/wall_panel.glb");
let bay = gander::model_instance(kit);               // the cargo bay's paint
gander::material_color(gander::model_material_named(bay, "Paint"), gander::hex(0x7a8a3a));
let lamp_a = gander::model_instance(lamp);            // this fixture flickers on its own
gander::material_emissive(gander::model_material_named(lamp_a, "Tube"), tube_col, 12.0 * flicker);
let ghost = gander::model_instance(crew);             // the same body, its every slot a hologram
for i: i32 in 0..gander::model_material_count(ghost) {
    gander::material_hologram(gander::model_material(ghost, i), gander::hex(0x40c8ff), 0.011, 0.28, 0.5);
}

An instance of a model_async model still streaming waits (draws nothing, model_ready false) and gets its copies when the model arrives. Freeing an instance frees only its materials; freeing the source while instances live keeps the meshes and textures until the last instance is freed (the source itself draws nothing from then on).

Built-in surface shaders

One per material, set on a copy (model_instance) or on your own material:

material_hologram(m, color, scan_m, band_m, dropout)   unlit, additive, brighter at the rim; scanlines every
                                  scan_m metres of world height scrolling up (0.011), bands band_m tall (0.28)
                                  dropping out (dropout 0..1), flicker, a bright line rolling up; a base texture
                                  keeps the figure's detail as brightness. color alpha scales it, 0 turns it off.
                                  Casts no shadow
material_refraction(m, strength)  what is behind, bent by the surface normal (and its normal map): strength is a
                                  screen fraction up close (0.02 heat shimmer, 0.06 an alien's skin, 0.1 thick
                                  glass), tinted by the colour, the surface's highlights and reflections on top.
                                  Transparent; casts no shadow (draw a shadow_only copy for one). The frame copies
                                  the opaque scene at half size only when a refraction draw is on screen; in a
                                  render view it is faint glass
material_crt(m, texture, corruption)   a CRT screen showing texture (a view_texture, a test card) on the mesh's
                                  0..1 uvs: barrel, bezel, scanlines, RGB triad mask, a rolling brighter band;
                                  corruption 0..1 adds a rolling tear, chroma bleed and noise. The emissive colour x
                                  strength is its brightness; the material's own colour and roughness are the glass
                                  (make it dark and glossy). Texture { 0 } turns it off
material_toon(m, shade, threshold, softness, rim, shadows)   cel shading: two flat tones. The lit tone is the base
                                  colour under the sun and a flat sky; the shaded tone is that times shade (a warm pink
                                  for skin, a cool blue for cloth). threshold where the edge falls (0.5 the terminator,
                                  lower lights more), softness its width (0.03 hard), rim a bright edge on the lit side
                                  (0 none), shadows how much cast shadows darken it (0: a face that ignores the hair's
                                  shadow). material_toon(m, shade) takes the defaults; shade alpha 0 turns it off
material_outline(m, color, width)   an ink line: the mesh drawn again inside out, pushed out along its normals. width is
                                  metres at 1 m and holds on screen (0.002 fine, 0.004 bold, 0 none). color alpha 1: that
                                  colour; alpha 0: the surface's own colour times color (each part lined in its own hue).
                                  Opaque materials; smooth normals give a closed line, hard edges gap at corners
model_toon(model, shade, line, width)   every material of a model: one shade tint and one line

Matrix helpers (all return gander::Mat4): identity() translate(p) scale(s) scale(float3) rotate_x/y/z(rad) rotation(q) trs(pos, q, scale) place(pos, yaw) place(pos, yaw, scale) mul(a, b) inverse(m) (compose with mul: a * b on two Mat4 compiles but multiplies entry by entry, which drops the translation and draws the object at the origin); position(m) transform_point(m, p) transform_dir(m, d). Quaternions: quat_identity() quat_axis(axis, rad) quat_euler(pitch, yaw, roll) quat_mul(a, b) quat_rotate(q, v). Helpers: forward(yaw, pitch) (camera direction), deg(d), approach(cur, target, step), damp(cur, target, rate, dt) (f32 or float3, frame-rate independent smoothing), v3(x, y, z), v2(x, y).

Streaming and detail levels

Big games should not make players download everything before the title screen. Files under assets/stream/ stay out of the start-up download and load only when the game asks:

var far_town: gander::Model;
fn main() { far_town = gander::model_async("assets/stream/town.glb"); }   // returns at once
// in frame(): draws nothing until it has arrived
if gander::model_ready(far_town) { gander::draw_model(far_town, town_xf); }
// leaving the region for good: give the memory back (model_async loads it again later)
gander::model_free(far_town);
model_async(path) -> Model    load in the background (any assets/ path; assets/stream/ downloads on demand)
model_ready(m) -> bool        true once loaded. An animator, face or ragdoll made before then waits: its calls
                              do nothing and it draws nothing until the model arrives, then it binds itself
                              (anim_retarget still needs both models ready)
model_free(m)                 release GPU + CPU memory; never draw it or its animators afterwards. Only the
                              textures its GLB brought are destroyed: a gander::texture you put on its
                              materials (material_texture) is yours and stays
assets_pending() -> i32       still downloading or loading (a loading bar; probe it in tests)
lod_bias(b)                   1 default; 0.5 keeps full detail twice as far, 2 drops it sooner (phones)

Every mesh over 1,500 triangles gets two simpler levels when it loads (about 40% and 12% of its triangles) and switches by its size on screen; shadows use the next level down. Nothing to do in game code. A model_async model is finished over several frames at the start of each: parsed, then its textures decoded (as many a frame as fit a few ms, at least one), then the meshes, and its detail levels after it is ready (it draws full detail until then). A 2K MetaHuman landing mid-game took one 590 ms frame and now spreads over about a second; the longest step left is one 2K normal map (about 60 ms on a desktop browser, several times that on a phone), so ask for big models behind a loading screen or a quiet beat, or keep streamed textures at 1024. The load log line gives each model's time by phase.

Model textures ship compressed: gander build re-encodes the PNG / JPEG textures inside GLBs as KTX2 (Basis Universal) and the engine keeps them block-compressed on the GPU (BC7, ASTC or ETC2, whichever the device has): about a quarter of the video memory and a much smaller download for 2K textures. assets/ is untouched; the first build of a new texture takes a few seconds (cached after that), and the build line says how many it converted. Keep source textures at sizes divisible by 4 (power-of-two sizes are); other sizes ship as they are. "compress_textures": false in gander.json turns it off (pixel-art textures on models, or to compare). Already-compressed 2K JPEGs (MetaHuman and scanned exports, normal maps above all) can come out bigger as KTX2: "compress_textures": "smaller" keeps each texture's JPEG / PNG where KTX2 would download bigger (those decode to full RGBA8 in video memory, 4x a compressed one), and "compress_textures_skip": ["assets/stream/*.glb"] keeps the textures of the matching files as they are.

Far copies of a model can be impostors (one flat card baked from the model, lit the same): terrain scatter does it by itself past its impostor distance (GANDER terrain), and draw_impostor(model, xf, fade) draws one wherever you choose (fade 0..1 dithers it in over the real model).

Render views: scopes, mirrors, monitors

view_texture(w, h) -> Texture     the world seen from another camera (up to 4, at most 2048 px)
render_view(t, cam_world, fov)    draw it this frame from cam_world (looks down its -Z); a view
                                  not rendered this frame keeps its last image
render_view(t, cam_world, fov, PostFx { .. })   the same with the view's own post (below)

A render view draws the world (models, terrain, sky, fog, transparent things) from its own camera into a texture before the main view. It leaves out the viewmodel pass (your gun and arms) and debug lines. Put it on an UNLIT material (material_flags(m, false, true), base color white): the texture holds linear light, so an unlit surface shows exactly what that camera sees, and the screen's tone mapping and bloom apply once, as they do to everything else. A lit material would shade it a second time and look dull. Orientation is handled for every backend: sample it with ordinary 0..1 UVs (top of the image at v = 0).

Cost: each view is another full draw of the scene from that camera (narrow FOVs cull most of it), so keep it small (a scope: 384 to 512 px) and call render_view only on frames it is on screen. A material never sees the view that is being drawn (it reads white there), so a monitor can show its own camera without breaking.

A see-through rifle scope (picture in picture): a disc in the scope's eyepiece shows the magnified view along the barrel while the rest of the screen stays at the normal FOV, aimed or not.

// main(): one view, one unlit lens material
scope_tex = gander::view_texture(512, 512);
scope_lens = gander::material(gander::WHITE, 0.0, 1.0);
gander::material_texture(scope_lens, gander::SLOT_BASE, scope_tex);
gander::material_flags(scope_lens, false, true);

// frame(), after W (the weapon's world transform, -Z along the barrel):
// the scope camera sits at the objective lens and looks down the barrel;
// fov = the camera FOV / magnification (70 / 4 = 17.5 for a 4x scope)
let scope_cam = gander::mul(W, gander::translate(v3(0.0, scope_h, scope_front_z)));
gander::render_view(scope_tex, scope_cam, 17.5);
// the lens: a disc (MESH_CYLINDER squashed flat, or your own fan) in the
// eyepiece, facing the player, drawn in the viewmodel pass with the gun
gander::draw_mesh_viewmodel(gander::MESH_CYLINDER, scope_lens, gander::mul(W, lens_xf));

Draw the reticle in the same pass, just in front of the lens (a thin cross of small dark boxes, or a ring), so it moves with the gun. Make the lens fill the scope tube's inner diameter and leave a dark rim (the tube) around it.

A view with its own post (a camcorder, a security camera, a scope's coated glass): pass a PostFx to render_view. Only that view's texture changes; the screen's post is untouched. It takes exposure (a multiplier: manual exposure), focus with aperture or f_stop (depth of field from the view's own depth; that view drops MSAA while it is on), bloom, vignette, grain, saturation, contrast, warmth and tint. bloom / vignette below 0 mean none here. The modes, auto exposure, blur, wobble, edge, double vision, refraction and tear are screen effects and are left out (draw a canvas over the screen for an OSD). The result stays linear light, so the unlit material showing it still goes through the screen's tone mapping once.

// the diary camcorder: f/1.8 with a slow focus hunt, vignette, grain, soft bloom, a little washed out
focus = focus + (subject_dist - focus) * min(dt * 1.5, 1.0) + sin(t * 3.1) * 0.02;
gander::render_view(cam_tex, cam_xf, 50.0, gander::PostFx { exposure: 1.4, focus: focus, f_stop: 1.8,
    vignette: 0.55, grain: 0.25, bloom: 0.6, saturation: 0.85 });

Cost per view on top of drawing it: one full-screen pass at the view's size, plus three small bloom passes (half, quarter and eighth size) when bloom is above 0, plus 13 depth taps per pixel with depth of field.

Text in the world

draw_text3d(font, s, xf, size, c, lit)        door stencils, labels, name tapes, signs
draw_text3d_ex(font, s, xf, size, c, Text3D { .. })

size is the line height in metres. The text runs along xf's +X with +Y up, facing +Z (one face), centred on xf's origin; \n starts a new line. text_width(font, s, size) is its width in metres. It is drawn like any mesh in the 3D pass: fog, shadows falling on it, culling. lit false shows its colour as it is (an unlit label); a colour with alpha below 1 blends.

Text3D fields: align (ALIGN_CENTER; ALIGN_LEFT / ALIGN_RIGHT put the origin at the start / end), lit (true), both_faces (false: true draws a turned copy so it reads from behind too, for a hanging sign; door stencils on both faces of a door are two calls, one per face), blend (false: alpha-tested, crisp, in the opaque pass; true: soft edges, sorted with transparent draws), emissive (0: glows its colour x this, a lit terminal label), shadows (false: true for raised lettering).

// a stencil 1 mm off the door's face (the door faces +Z here)
gander::draw_text3d(gander::DEFAULT_FONT, "AIRLOCK 3", gander::mul(door_xf, gander::translate(v3(0.0, 0.5, 0.051))),
                    0.18, gander::hex(0xf0e8d0), true);
// a glowing terminal label
gander::draw_text3d_ex(f, "CHECKPOINT ACTIVE", xf, 0.12, gander::hex(0x40ff80), gander::Text3D { lit: false, emissive: 2.0 });

Each different text is built into a mesh the first time it is drawn and kept while it is drawn (up to 256 different texts at once; ones not drawn for a while are dropped); changing text (a counter) builds a new one each time it changes, a few glyphs of CPU work. The font gets a 1024 x 1024 RGBA copy of its glyph atlas with mips (5.6 MB of GPU memory) the first time it is drawn in the world. For a big block of changing text on a screen, draw it into a canvas instead.

3D: animation

animator(model) -> Anim            one per character; many animators may share a model
animator_free(a)                   a spawn gone for good: its pose, skin textures and state are freed (attached
                                   emitters and faces on it stop; free its ragdoll first); the handle is dead
anim_reset(a)                      reuse one (a respawn): rest pose, no clips, overrides, root motion, dissolve,
                                   foot IK and warp cleared, as if just made
anim_play(a, clip, layer, looping, fade, speed)   replaying the current clip does nothing,
                                   so call it every frame; layer 0 base, 1..7 overlays
play(a, clip)  play_once(a, clip)  layer 0 shorthands (loop / once, short crossfade)
anim_stop(a, layer, fade)  anim_mask(a, layer, "Spine")  (overlay only that bone's subtree)
anim_mask_exclude(a, layer, "upperarm_r")   ...but not this subtree (2 per layer): a stance on the body, the sword arm free
anim_weight(a, layer, w)  anim_speed(a, layer, s)  anim_time  anim_seek  anim_progress -> 0..1
draw_animated(a, xf)
anim_rate(a, n)  anim_due(a)       re-pose every n-th frame (far characters: 3-4 past ~30 m; staggered so a
                                   crowd spreads out); anim_due says if this frame re-poses: skip your IK between
bone(a, name) -> Mat4              model space; mul(model_xf, bone) for world (attach a weapon)
bone_rotation(a, name, q, weight)  persistent override (weight 0 clears)
bone_add_rotation(a, name, q)      this frame, on top of the animation (recoil, aim offset)
ik(a, end_bone, target, pole, weight)       two-bone IK, model space (hand on a door, foot on a step); a
                                   near-straight limb bends straight toward the pole (no roll of the whole leg)
ik_orient(a, end_bone, target_xf, pole, weight)   the same to a whole frame: position AND rotation (palm flat, forearm rolled)
ik_two_hand(a, "hand_r", "hand_l", weapon_xf, grip, pole, weight) -> Mat4   off hand on a two-handed weapon;
                                   turns the weapon about the main wrist when the grip is out of reach: draw it with the result
look_at(a, bone, target, fwd, weight, max_deg)   head / spine tracking, model space
spring_bones(a, "hair_", stiffness, drag, gravity)   hair, tails, ribbons, ears: every bone whose name starts with the
                                   prefix trails its parent with inertia. Call once a frame after the pose is set (clips,
                                   look_at, ik). stiffness pulls back to the animated pose (1 soft .. 4 stiff), drag 0..1
                                   (0.3), gravity 0.2 light .. 1.5 heavy. In model space: it feels the skeleton move (a head
                                   turning), not the character travelling
morph(a, blendshape, weight)       set a blendshape by name
anim_additive(a, layer, true)      layer 1..7 ADDS its clip (change from the clip's first frame) on top:
                                   hit flinches, breathing, recoil over any walk / run / idle
anim_crossed(a, layer, seconds) -> bool   the clip passed this time this frame: hit frames, footsteps, sounds
anim_crossed_marker(a, layer, "hit") -> bool   the clip passed one of its markers called "hit" this frame
anim_marker(model, clip, "hit", 0) -> f32       the time of a clip's first "hit" marker, -1 none
blendspace() -> BlendSpace   blendspace_add(b, clip, x, y)   clips on a plane (x sideways, y forward m/s)
blendspace_match_speed(b, true)   clips placed at their ground speeds: the cycle speeds up / slows to the real speed
anim_blend(a, layer, b, x, y, fade)   every frame: the clips around (x, y) blended, all on one phase so the
                                   feet stay in step; markers and root motion follow the strongest clip
anim_set(a, layer, clip, t, weight)   pose a layer at an exact clip time and weight, no clock: the game
                                   drives time every frame (anim_play / anim_stop hand the layer back)
anim_set_fade(a, layer, seconds)   anim_set changing clip (or restarting it) on that layer cross-fades from the last pose (0 cuts)
anim_root_motion(a, "Hips")        the base clip's travel stops moving the mesh...
anim_root_delta(a) -> float3       ...read it each frame (model space) and move the character by it
anim_root_turn(a, true)            root motion also takes the root bone's yaw out of the pose (turn clips)...
anim_root_yaw(a) -> f32            ...this frame's turn, radians about +Y (positive = left for a model facing +Z)
anim_clip_yaw(model, clip) -> f32  a clip's whole keyed turn, radians (turn_l 1.57, turn_180 3.14)
anim_dissolve(a, amount, edge_color)   burn the body away 0..1 (top down, glowing edge, shadow too): see Particles
anim_retarget(to_model, from_model) -> i32   copy from's clips onto to's skeleton (once, in main); twist
                                   joints (lowerarm_twist_01_l ...) take their share of the limb's roll
anim_retarget(to, from, RetargetOpts { twist: false }) -> i32   the same with the twist joints left at rest
                                   (MakeHuman / Mixamo bodies work as they come: the Armature's rest turn and
                                   scale are allowed for, and a face mesh named "Head" is not taken for the head bone)
anim_retarget_map(to, from, out) -> i32   the joints it matches, one "to <- from" line each; "(twist: 0.67 of
                                   hand_r's roll)" for twist joints; "(not driven)" for joints left at rest
                                   (correctives, toes, metacarpals): probe it when a hand or foot looks off
anim_orient_warp(a, angle, max_deg)   hips and legs turned by angle (radians, + left) toward the way it moves,
                                   the spine back so the chest faces forward: one walk walks diagonally / sideways
anim_foot_ik(a, xf, FootIk {}) -> f32   feet on stairs and slopes: rays down to the physics world, foot to the
                                   ground, ankle to its slope, pelvis lowered as needed; returns the pelvis offset.
                                   Off past FootIk.lod_distance (35 m) and behind the camera; planted feet reuse rays
anim_foot_ik_bones(a, "foot_l", "foot_r", "pelvis")   only when the names are not found (foot_l / LeftFoot / foot.L)

Retargeting matches bones by name within a rig family and by humanoid role across families (Meshy / Mixamo, Forge / Unreal, Blender names), bakes each clip at 30 fps and adds it to to_model under the same name (a name it already has is kept). The rigs may rest in different poses and face different ways (a Meshy T-pose facing +Z onto a Forge A-pose facing -Z): each bone is first turned into the source's rest pose, then given the clip's motion, so do not bake clips across rigs by hand. One Meshy run cycle can then drive every humanoid in the game.

let hero = gander::model("assets/models/hero.glb");
let moves = gander::model("assets/models/moves.glb");      // a rig that has run / jump / attack clips
let n = gander::anim_retarget(hero, moves);                 // hero now plays "run", "jump", "attack"
// a swing that lands on its hit frame, with a flinch layer added on top
if gander::anim_crossed(a, 0, 0.42) { apply_hit(); gander::time_scale(0.0); hitstop = 0.06; }
gander::anim_play(a, "HitReact", 1, false, 0.05, 1.0);
gander::anim_additive(a, 1, true);

Locomotion is a blend space, not a clip picked by speed: walking, jogging and strafing blend into each other with the feet in step, and the speed between two clips is a mix of both. Put the clips at the ground speeds they move at (a clip's own travel: anim_root_delta, or the walk's stride), and give anim_blend the character's velocity in its own frame each frame:

let loco = gander::blendspace();
gander::blendspace_add(loco, "idle", 0.0, 0.0);
gander::blendspace_add(loco, "walk", 0.0, 1.4);
gander::blendspace_add(loco, "run", 0.0, 4.0);
gander::blendspace_add(loco, "strafe_left", -1.3, 0.0);
gander::blendspace_add(loco, "strafe_right", 1.3, 0.0);
gander::blendspace_add(loco, "walk_back", 0.0, -1.2);
gander::blendspace_match_speed(loco, true);   // a 4 m/s lock-on strafe plays the side step faster, feet planted
// frame: local = velocity turned into the character's facing (x right, y forward)
gander::anim_blend(a, 0, loco, local.x, local.y, 0.2);
gander::anim_play(a, "sword_idle", 1, true, 0.2, 1.0);       // the stance over the upper body...
gander::anim_mask(a, 1, "spine_01");
gander::anim_mask_exclude(a, 1, "upperarm_r");                // ...but the sword arm is the move layer's

Moving at an angle to the way the character faces (circling a locked-on foe, a strafe the library has no clip for): anim_orient_warp turns the hips and legs toward the motion and the spine back over its bones, so the chest, head and arms keep facing forward while a forward walk or run strides along the real direction (planted feet do not slide). The angle is the motion's yaw from the facing, the same sign as anim_root_yaw (+ left); it eases over ~0.12 s, so give it raw every frame. Past about 90 degrees play the back clip instead and warp it by the rest. Call it after the blend, before foot IK:

// local: velocity in the character's frame (x right, y forward), as given to anim_blend
let spd = length(local);
if spd > 0.3 {
    let back = local.y < -0.2;
    let fwd_y = if back { -local.y } else { local.y };
    let warp = atan2(if back { local.x } else { -local.x }, fwd_y);   // + left, 0 straight on
    gander::anim_blend(a, 0, loco, 0.0, if back { -spd } else { spd }, 0.2);   // only the forward / back column
    gander::anim_orient_warp(a, warp, 90.0);
} else {
    gander::anim_blend(a, 0, loco, local.x, local.y, 0.2);
}

Strafes and back-steps that a clip library lacks can still come from a Meshy rig (anim_retarget carries them across rigs); a blend space with real diagonals needs no warp (or a small one for the angles between its clips).

Feet on stairs and slopes. anim_foot_ik(a, xf, FootIk {}) after posing (and after anim_orient_warp), before draw_animated, with the same xf: each foot casts a ray down through its ankle and its ball (a step edge under the toes holds the foot up), the foot keeps its clip motion relative to the ground it finds (a swing stays a swing, a planted foot stays planted while a character controller pops up a step), the ankle turns to the slope while the foot is planted, and the pelvis goes down only as far as the lower leg needs to reach. Offsets ease in the world (smooth, rising 4x faster so a foot never sinks into a step); a foot whose ray finds nothing goes back to its clip; pass on_ground: character_on_ground(c) so it fades out in jumps and falls. The rays hit physics bodies (static meshes, boxes, terrain_physics), not characters, ragdolls or debris (mask). The clip's ground is the model's origin (y 0, where you draw the character); foot_height overrides it for a model that stands elsewhere. A character past lod_distance (35 m) from the camera, or behind it, is skipped (the clip's own feet; it fades over the last fifth), a planted foot reuses its last rays (a standing crowd casts about one ray in seven frames, a walk saves a third), and under anim_rate it solves only on the frames the pose is re-evaluated: 8 fighters cost 0.20 ms of foot IK on a 4x-throttled phone CPU (0.55 ms before 2026-11-05). Set morphs and bone edits for the frame before or after foot IK freely: morph() no longer re-poses the character (it threw the foot IK away). Measured on a 15 cm stair: planted feet 1.2 to 1.8 cm off the treads on average (the walk clip's own heel roll), deepest dip 2.4 cm, against 4 to 5 cm and 31 cm into a step without it.

let xf = gander::place(gander::character_pos(c), yaw);
gander::anim_blend(a, 0, loco, local.x, local.y, 0.2);
gander::anim_foot_ik(a, xf, gander::FootIk { on_ground: gander::character_on_ground(c) });
gander::draw_animated(a, xf);

Markers and sockets live in the GLB. A clip's markers are named times in its glTF extras ({"markers": {"hit": 0.42, "step": [0.1, 0.6]}}): gander glb markers lists and writes them, and they travel with anim_retarget, so a retargeted swing still lands its hit. A socket is an empty node parented to a bone, and bone(a, "weapon_r") reads it like any bone: gander glb socket adds one (--bone hand_r --offset x,y,z --rotate pitch,yaw,roll in meters and degrees, in the bone's frame), so a weapon's grip is measured once, not in code.

A skeleton onto a new body. gander rig transfer gives an unrigged body (a scan, a MetaHuman or sculpt export, a second character) a rigged model's skeleton, fitted to its proportions and pose (a T-pose rig takes an A-pose body; facing is worked out), skin weights from the rigged body's nearest points, and its clips, so every clip made for one rig plays on the other. --armour piece.glb (fitted on the body) skins armour with 2 bones per piece so plates stay rigid; gander rig bind does the same for gear on a model that is already rigged. Use these instead of writing a skinning script: they keep memory bounded on any mesh size. The joints are fitted inside the body and each bone's frame turned onto it, so a body standing upright takes a rig whose source rests in a crouch; at rest the body takes the source's stance, so its clips play as they are. Weights come from the fitted bones (distance to each bone, smoothed over the mesh; loose fur cards and straps move whole); --weights source copies the rigged body's own instead (only for a body built and posed like it). A body that is already rigged (MakeHuman, Mixamo, a gander face human character) and will talk on camera takes --weights keep-head: the fitted bones' weights everywhere but the head and neck, which keep the body's own (the face whole on the head, the neck fading into the chest as the body had it), with the rig's head and neck joints put on the body's own pivots. With plain bone weights a MakeHuman face carried 37% neck on average and sheared on a close-up; --rigid Head tore the neck seam instead. --rigid NAME[=BONE] holds a mesh or armour part on one bone (a face mask or helm on the head); rig bind --rigid Head does the same for gear, rig bind --allow Head,neck_01,spine_03 limits a hood to those bones. Rigs made by rig transfer before 2026-11-04 shear at the joints and carry their helms on the arms: make them again (the bind-pose body comes back out of the old rig unchanged). gander rig reseat rigged.glb only stops the shearing in place, for when the body can't be rebuilt.

Twist joints. A MetaHuman, UE5 or Character Creator rig skins the forearm and upper arm (and thigh and calf) to twist joints hung off the limb (lowerarm_twist_01_l, upperarm_twist_02_l, calf_twist_01_l). A source rig has none, so anim_retarget gives each its share of the roll by where it sits along the limb: a forearm twist joint takes that fraction of the hand's roll about the forearm (0.33 and 0.67 on a MetaHuman), an upper-arm or thigh twist joint takes back (1 - fraction) of the limb's own roll, so the wrist turns without candy-wrapping and the shoulder skin stays with the body. Measured on a MetaHuman with an 80 degree wrist roll: the twist left at the wrist is a third of the hand's (32 degrees against 98); the hand bone itself is the same either way. anim_retarget(to, from, RetargetOpts { twist: false }) keeps them at rest; twist_amount scales every share. Keep the twist joints when you prune a rig (gander rig prune keeps every joint a vertex uses): a rig whose twist weights were folded onto the limb has nothing left to twist.

A skin takes at most 256 joints (joint indices are 8-bit). A MetaHuman (or any rig with a face skeleton) exported with its face shapes baked as morph targets needs no face joints: gander rig prune hero.glb --merge "FACIAL_*=head" folds their weights onto the head and leaves every joint no vertex uses out of the skin (645 joints to about 170), keeping the chain joints between (a MetaHuman's upper arm and thigh carry no weight of their own, their twist joints do). An already rigged character like that needs no rig transfer: its clips come from anim_retarget(it, maren) at load, by joint name.

gander rig transfer assets/models/maren.glb assets/src/brannock_body.glb -o assets/models/brannock.glb \
    --armour assets/src/cuirass.glb --armour assets/src/greave_l.glb
gander rig transfer assets/models/maren.glb assets/src/brute.glb -o assets/models/brute.glb --rigid Body_face
gander rig bind assets/models/raider.glb assets/src/helm.glb -o assets/models/raider.glb --rigid Head --name Helm
gander rig bind assets/models/raider.glb assets/src/hood.glb -o assets/models/raider.glb --allow Head,neck_01,spine_03,clavicle_l,clavicle_r
gander glb info assets/models/hero.glb                       nodes, skins, clips with their markers
gander glb markers assets/models/hero.glb sword_attack hit=0.42 step=0.1,0.6
gander glb socket assets/models/hero.glb weapon_r --bone hand_r --offset 0.02,0.08,0.0 --rotate 0,0,90
// a move whose clip time the fight code computes: its hit frame pinned to the move's active window
let hit = gander::anim_marker(hero, "sword_attack", "hit", 0);
gander::anim_set(a, 1, "sword_attack", clip_t, 1.0);
if gander::anim_crossed_marker(a, 1, "hit") { deal_damage(); gander::pad_rumble(0.8, 0.4, 0.15); }
gander::draw_model(sword, gander::mul(hero_xf, gander::bone(a, "weapon_r")));

Turning in place with turn clips (turn_l / turn_r / turn_180, the way a creature turns to face a target instead of sliding round over its idle): anim_root_motion + anim_root_turn make the clip's turn the actor's, so the body does not also turn in the pose (no double turn). Pick the clip by the angle, scale its keyed turn (anim_clip_yaw) to exactly that angle (and its speed, so smaller turns are quicker), and add anim_root_yaw times that scale to the actor's yaw every frame (the clip's own ease is the turn's ease); back to the idle when it ends.

// once: gander::anim_root_motion(c.anim, "pelvis"); gander::anim_root_turn(c.anim, true);
fn start_turn(c: Creature&, want: f32) {             // want: radians to turn, + left
    let clip = if abs(want) > 2.2 { "turn_180" } else if want > 0.0 { "turn_l" } else { "turn_r" };
    let keyed = gander::anim_clip_yaw(hero, clip);    // turn_l 1.57, turn_r -1.57, turn_180 3.14
    c.turn_scale = want / keyed;                      // lands on want; negative mirrors a left-only 180
    // smaller turns play quicker, so the turn rate stays about the clip's own
    gander::anim_play(c.anim, clip, 0, false, 0.15, clamp(1.0 / abs(c.turn_scale), 0.7, 1.6));
    c.turning = true;
}
// each frame while turning
c.yaw += gander::anim_root_yaw(c.anim) * c.turn_scale;
if gander::anim_progress(c.anim, 0) >= 1.0 { c.turning = false; gander::play(c.anim, "idle"); }
gander::draw_animated(c.anim, gander::place(c.pos, c.yaw));

turn_scale makes the actor land on the exact angle while the clip plays in full; a body turning further than its clip (scale over ~1.3) slides on its feet, so keep clips for 90 and 180 and let the speed cover the rest. anim_root_delta is given in the turned frame, so a walk-turn clip's travel goes the right way with the same place(pos, yaw).

Clip names: exact, then case-insensitive, then the part after | (Armature|Run), then a substring. A wrong name logs the clips the model has. Order each frame: play / pose edits / IK, then draw_animated.

Audio

sound(path) -> Sound               WAV / MP3 / OGG / FLAC, decoded up front (effects, voice lines)
sfx(s)  sfx(s, vol)                fire and forget with slight random pitch
play(s, vol, pitch) -> u32 voice   play_loop(s, vol, pitch)
play_loop_at(s, offset)  play_loop_at(s, vol, pitch, offset)   a loop started offset s in (wrapped to its
                                   length); offset < 0 starts it anywhere at random (room tones, beds, hums)
play3d(s, pos, vol, range)  play3d_loop(...)     heard from the 3D camera
play3d_loop_at(s, pos, vol, range, offset)       the same for a 3D loop
play_voice(s, vol)                 dialogue bus (faces lip sync to it)
voice_stop(v) voice_volume(v, vol) voice_pitch(v, p) voice_position(v, pos) voice_playing(v)
voice_lowpass(v, hz)               a low-pass on one voice (a radio, through a wall); 0 open. With occlusion and
                                   air absorption the lowest cutoff wins
audio_air(near, far, far_hz)       3D voices lose their highs with distance: open to near m, far_hz at far m
                                   (a battlefield: 15, 300, 2400); off by default
audio_far_reverb(amount, near, far)   a long dark reverb distant 3D sounds feed: no send to near m, amount at
                                   far (gunfire rolling across a field); 0 off
music(path, vol, fade)             streamed, looping, crossfades; same path again glides to vol over fade
music(path, vol, fade, offset)     starting offset s into the track (< 0 at random)
music_volume(vol, fade)            the playing track's volume (tension rising from beat to beat)
music_preload(path)                open the next track now (a quiet beat, a loading screen): the music()
                                   that switches to it then costs nothing (opening a stream is 30-40 ms on a phone)
music_stop(fade)
bus_volume(BUS_MASTER|BUS_MUSIC|BUS_SFX|BUS_VOICE, vol)
bus_lowpass(bus, hz)  bus_lowpass(bus, hz, fade)   a low-pass on a bus gliding to hz over fade s (log
                                   frequency); 20000 opens it. On BUS_MASTER: the breach / shell-shock muffle
audio_limiter(on, ceiling_db, release)   the master limiter: on by default at -1 dBFS, 0.15 s release
audio_meter() -> AudioMeter        the master since the last call: peak_db, rms_db (dBFS, after the limiter),
                                   limiter_db (its deepest gain, 0 untouched), in_peak_db (going into it)
sound_length(s) -> f32
audio_space(AudioSpace)             how everywhere sounds (outside zones)
audio_zone(center, size, AudioSpace, margin) -> i32   a box that sounds different while the camera is in it
audio_zone_remove(id)  audio_zones_clear()
audio_occlusion(on)                3D sounds behind static walls go muffled and quieter

Audio starts with the player's first click, tap or key (browser rule).

Spaces give sfx and voices (never music) the room they are in: echo and a muffle filter, eased as the camera walks between zones. Presets: space_outdoors(), space_forest(), space_room(), space_tiled() (bathroom, kitchen), space_hall(), space_cave(), space_tunnel(), space_underwater(), or your own AudioSpace { reverb, size, damp, muffle } (each 0..1). Zones are boxes; the smallest one the camera is in wins, so a room zone inside a building zone works.

A space ported from EAX-style presets (Unreal / Wwise reverb settings) sets the tail directly: decay (RT60 in seconds, replacing size), hf_ratio (the highs' decay time against the lows', 0.1..1, replacing damp: 0.5 is a stone hall, 0.9 a tiled room), predelay (seconds before the tail, up to 0.25), early (the first reflections off near walls, 0..1; their spacing follows size) and late (the tail's level, 0..1). reverb stays the overall wet level. Measured from an impulse in a gander capture (Schroeder decay): decay 0.8 rings 0.86 s, 2.5 rings 2.63 s, 1.2 with predelay 0.1 rings 1.25 s and its tail starts 100 ms after the hit, early reflections in between.

// a concrete stairwell: long, bright, a clear first slap off the walls
gander::audio_zone(stair_c, stair_size, gander::AudioSpace { reverb: 0.5, size: 0.6, decay: 2.4, hf_ratio: 0.7,
                   predelay: 0.02, early: 0.6, late: 0.8 }, 1.5);
// a breach: everything muffled at once, clearing over four seconds
gander::bus_lowpass(gander::BUS_MASTER, 300.0, 0.0);
gander::bus_lowpass(gander::BUS_MASTER, 20000.0, 4.0);
// room tones that do not all start on the same bar
let hum = gander::play3d_loop_at(hum_s, gen_pos, 0.6, 18.0, -1.0);
gander::music("assets/music/ambient_bed.ogg", 0.5, 2.0, -1.0);

Every mix runs through a peak limiter at the end (on by default, -1 dBFS): twenty gunshots at once no longer clip, they duck for 0.15 s. audio_meter() reads the master output (peak, RMS, how hard the limiter worked) for a level meter or to set a mix by numbers: a limiter_db past about -3 most frames means the mix is too hot; turn the buses down rather than lean on it.

gander::audio_space(gander::space_outdoors());
gander::audio_zone(gander::v3(0.0, 3.0, -40.0), gander::v3(30.0, 8.0, 50.0), gander::space_cave(), 3.0);
gander::audio_occlusion(true);        // shooters, horror: footsteps behind a wall sound behind a wall
if swimming { gander::audio_space(gander::space_underwater()); }

Faces

Talking characters: lip sync, emotions, blinks and gaze on any model with blendshapes. Recognized automatically: ARKit 52 (Apple names or _L / _R names), Oculus visemes (viseme_aa ...), VRM presets (A I U E O, Blink, Joy ...). The face writes blendshape weights on its animator every frame before frame(), so draw the animator as usual.

face(anim) -> Face                 logs how many ARKit / viseme / VRM shapes it found
face_say(f, line, vol) -> u32      plays the line on the voice bus and lip syncs to it
face_say3d(f, line, pos, vol)      same, positioned (heard from the camera)
face_listen(f, voice)              lip sync to a voice you started with play_voice
face_listen_mic(f, on)             lip sync to the microphone, live (open it first with mic(true))
mic(on) -> bool  mic_level() -> 0..1   the default microphone, 16 kHz mono; the web asks permission
face_talking(f) -> bool  face_talk_level(f) -> 0..1  face_viseme(f) -> 0 sil .. 14 U
face_speech(f, amount)             how far lip sync opens the mouth: 1 as authored, 0.5 for a VRoid avatar (its A is a shout)
face_emotion(f, name, amount, fade)    happy sad angry surprised afraid disgust smirk thinking pain neutral
face_look(f, world_point, weight)  eyes follow a point (weight 0: idle gaze with saccades)
face_shape(f, "jawOpen", w)        set one ARKit channel; w < 0 hands it back
face_blink(f, on)  face_blink_now(f)
var hero: gander::Anim;
var hero_face: gander::Face;
var hello: gander::Sound;

fn main() {
    gander::config("Talk", 1280, 720, 0);
    hero = gander::animator(gander::model("assets/models/hero.glb"));
    hero_face = gander::face(hero);
    hello = gander::sound("assets/voice/hello.mp3");
}

export "gander_frame" fn frame(dt: f32) {
    if gander::action_pressed("interact") && !gander::face_talking(hero_face) {
        let v = gander::face_say(hero_face, hello, 1.0);
        gander::face_emotion(hero_face, "happy", 0.7, 0.4);
    }
    gander::face_look(hero_face, gander::camera_pos(), 1.0);
    gander::look_at(hero, "Head", gander::camera_pos(), gander::v3(0.0, 0.0, 1.0), 0.6, 45.0);
    gander::camera(gander::v3(0.0, 1.6, 1.2), gander::v3(0.0, 1.55, 0.0), 35.0);
    gander::draw_animated(hero, gander::identity());
}

Lip sync analyzes the line when it starts (energy, fricatives, formants -> 15 visemes every 10 ms). For the best sync put a Rhubarb Lip Sync track next to the audio: assets/voice/hello.visemes.json (Rhubarb's JSON export) is used automatically. Voice lines from the voice skills (Gemini TTS, Chatterbox) work as is. Models need face blendshapes: Meshy and Forge characters have none (see the gander-gamedev skill for heads that do). The eyes are driven by eyeLook* shapes; turn the head with look_at on the head bone. In gander test, faces follow game time instead of the audio clock, so lip sync shows in screenshots.

Anime / VTuber avatars: a VRM file (VRoid Studio, any VRM 1.0 or 0.x) is a GLB with its own names. python3 $F/vrm_import.py avatar.vrm assets/models/avatar.glb renames its expression shapes and humanoid bones to the names above (A I U E O, Blink, Joy ..., mixamorig:Head), and gander reads each material's VRM cel shading (MToon: two tones and the ink line) as material_toon + material_outline when the model loads. A VRM 0.x file (VRoid Studio) also gets its spring bones renamed spring_... (spring_bones(a, "spring_", 3.0, 0.25, 0.25) swings its hair and skirt) and is turned to face +Z; its bones' own axes are turned with it, so give look_at a fwd worked out from bone() rather than (0, 0, 1). Use face_speech(f, 0.5) and emotions at about half strength on these faces. make_human.py --style toon makes one of your own (see faces-assets).

Live lip sync (a streamer's avatar, voice chat): mic(true) then face_listen_mic(f, true). The same analysis runs on the newest 32 ms of the mic each frame, with a noise floor that adapts to the room; there is no look-ahead, so it trails speech by about a frame. Lines the face says still win while they play. In OBS, a browser source needs OBS started with --enable-media-stream for the page to reach the mic.

Analytics and high scores

Plays and playtime reach the owner's Stats on their own (only on vbgnt; tests and local builds report nothing). Exported native builds (Windows, macOS, Linux, Android, iOS) report too, labelled with their platform, and use the same online boards as the web build; gander test --native stays quiet. Online high scores use the device's own table whenever the server is not reachable, so the same code works everywhere.

milestone(name, value)             a progress step (a-z 0-9 _), once per save; value e.g. minutes, -1 none
scores_start_run(board)            when a run begins (server-timed token for the next submit)
scores_submit(board, score, name, lower_is_better, time_format) -> req
                                   whole numbers; times in milliseconds with time_format true
scores_fetch(board, limit) -> req  top 1..100
scores_done(req) -> bool           poll; then:
scores_count(req)  scores_name(req, i, out&)  scores_value(req, i) -> i64  scores_rank(req, i)
scores_is_me(req, i)  scores_my_rank(req)  scores_improved(req)  scores_from_server(req)  scores_is_time(req)
scores_best(board) -> i64  scores_has_best(board)      this device's best
player_name(out&)  set_player_name(name)                remembered on the device (16 chars)
scores_online() -> bool
draw_scores(req, x, y, w, size) -> height               a ready-made table, the player's row highlighted
format_score(out&, value, is_time)                      "1:23.45" for times
var board_req: i32 = 0;
var score: i64 = 0;
var game_over = false;

fn end_run() {
    game_over = true;
    board_req = gander::scores_submit("main", score, "", false, false);
}

export "gander_frame" fn frame(dt: f32) {
    if game_over {
        gander::text_center("GAME OVER", gander::width() * 0.5, 60.0, 56.0, gander::WHITE);
        let h = gander::draw_scores(board_req, gander::width() * 0.5 - 200.0, 150.0, 400.0, 28.0);
    }
}

Performance

Every engine phase is timed each frame (input, faces, arms, game, anim, physics, audio, render3d with shadow / main / terrain / post, render2d), with draw calls, triangles, skinned meshes, wasm heap and texture memory.

perf_begin("ai") ... perf_end("ai")    time a part of your frame (shows as a zone)
perf_overlay(true)                     the overlay (also F3, or ?gander_perf in the URL)
perf_cpu_ms() -> f32  perf_fps() -> f32  perf_draws() -> i32   (cpu_ms and fps are averages over the last
                                       60 frames, for an on-screen readout; never use them to find one slow frame:
                                       read perf.json from gander test --perf)

gander test --perf writes perf.json (and prints a summary): frame CPU percentiles, game logic time, per-zone costs, draws, triangles, memory, the five worst frames with their zones, late_gpu (every pipeline, shader and render target made after frame 3, with its frame: the usual cause of a one-off hitch, so build or warm that thing earlier) and warnings against budgets. A shadow_fill zone (inside shadow) is the light tiles' still geometry being redrawn. To find which Pro pass a cost belongs to, run with --opt pro=1 --opt pro_skip=N (1 AO, 2 bounced light, 4 reflections, 8 sunlit fog, 16 TAA; add them up). Add --mobile to throttle the CPU 4x like a phone. The first 20 frames are warm-up (shader compiles) and reported separately. The frames right after each --shots screenshot are the harness's, not the game's: they are left out of every number and listed in excluded (frame, cpu_ms, reason), so a run with shots and one without read the same. worst and excluded give no_zone_ms, the frame time inside no zone; stream, uploads and submit zones hold loading streamed models, sending GPU uploads and handing the frame to the GPU (where a busy GPU makes the CPU wait). On --gpu the frames are stepped one at a time with the GPU idle before each, like a real browser, so frame times are the engine's own work (perf.json harness). In the agent's tests the GPU is emulated in software: trust game logic time, draw calls, triangles and memory, not render times. There is no GPU timer: the report has CPU zones and frame times only. Fix what the warnings name: instance repeated meshes, texture_limit(1024) for heavy generated models, load before play.

Memory in perf.json (memory_mb, also printed as perf memory lines): in_use is what malloc holds, free_in_heap the rest of the wasm heap (the heap never shrinks, so a big number there is a passing peak), and owners splits it by who holds it, each with now and peak: models (CPU copies of meshes and keyframes), textures (kept pixels of texture()), sounds (decoded audio), terrain, physics (Jolt, Box2D), nav, particles, ui (fonts), uploads (texture upload buffers), engine. package is the assets the page holds beside the heap. game is the estimate of what is left: your Goose globals and anything no owner counts. growth lists the biggest heap growths with the frame and what was loading (loading assets/x.glb, making a terrain, building a navmesh, game code or engine); the log has a heap grew A -> B MB ... line for each. When the phone heap warning fires it names the biggest holders and growth: free or stream that, or make it smaller (a lower terrain resolution, texture_limit, fewer mesh colliders).

The top-level numbers cover the last 600 frames only (the history the overlay draws). perf.json run covers the whole run without keeping its frames: cpu_ms, cpu_logic_ms and every zone as streaming percentiles (log bins about 10% wide, so read p95 within about 5%), the five worst frames of the whole run with their frame numbers and zones, and segments: per segment_frames frames (600, doubling past 96 segments) the p95, logic p95, max and the costliest zone (top, top_avg). The gander test summary prints a perf run line and the segments that stand out, so a slow stretch in minute two of a ten-minute --gpu run shows up even though the end was fine. shadow_tiles_drawn() is 0 after a frame that was not drawn.

Groups: an area that loads and frees as one

The streaming unit of a level with areas (an Unreal port's sublevels): open a group, make the area, close it; free it when the player leaves.

var fen = gander::group("fen");
fn load_fen() {
    gander::group_begin(fen);
    fen_ground = gander::terrain_load_at("assets/terrain/fen.png", -946.0, -316.0, 946.0, 632.0, -8.0, 30.0);
    gander::terrain_physics(fen_ground, true);
    fen_props = gander::model_async("assets/stream/fen_props.glb");
    for p in fen_colliders { gander::body_box(p.pos, p.half, 0); }
    fog_banks.push(gander::fog_volume(...));
    reverb = gander::audio_zone(...);
    gander::group_end();
}
// loading screen or band edge: wait for it
if gander::group_ready(fen) { ... }          // every model_async in it arrived; group_pending(g) counts the rest
// the player crossed into the climb band:
gander::group_free(fen);                     // all of it; load_fen() with a new group() to bring it back
group(name) -> Group            a new, empty group
group_begin(g) / group_end()    what is made in between joins g: models (model, model_async), bodies, joints,
                                characters, cloth, decals, terrains, fog volumes, post zones, audio zones
group_free(g)                   frees all of it (joints and characters first, then terrains, bodies, models, the rest)
group_ready(g) -> bool          every model_async of the group has arrived
group_pending(g) -> i32         its models still streaming in
group_count(g) -> i32           what it holds
group_loaded(g) -> bool         made and not freed yet

A model that code outside the group (or another group) also loads is shared and no group frees it: the lantern every area uses stays. Sounds, textures, fonts, particle systems and animators are never freed by a group; lights are drawn every frame, so there is nothing to free. Keep game state (which creatures died, which gates are open) in your own arrays, not in the group, and put it back when the area loads again. Open one group at a time, inside one function (do not leave it open across frames).

Data files (JSON)

Tables the game reads at run time instead of generated Goose: creature stats and moves, level layouts, tuning. A data change then needs no code rebuild and no regenerated source. Files live under assets/ (packed like any asset); comments and trailing commas are allowed.

let c = gander::data("assets/data/creatures/RisenBrother/creature.json");
let hp = gander::data_f(c, "stats.hp", 100.0);
let n = gander::data_len(c, "moves");
for i: i32 in 0..n {
    var key: u8[>..] = [];
    format(key, "moves.", i, ".windows.active.0");
    let start = gander::data_f(c, key, 0.0);
    if gander::data_is(c, "moves.0.kind", "lob") { ... }
}
let spawn = gander::data_vec(c, "spawn", float4 { 0.0, 0.0, 0.0, 0.0 });   // [x, y, z] or {x, y, z}
data(path) -> Data                 parse once (the same path again returns the same handle); a bad file warns with
                                   its line and reads as empty
data_parse(text) -> Data           JSON the game holds (a network message)
data_free(d)
keys                               "stats.hp", "moves.2.name", "moves[2].name", "" the root
data_f / data_num / data_int / data_bool (d, key, default)   numbers (bools read as 0 / 1)
data_str(d, key, out&) -> bool     appends the text (numbers and bools as text)
data_is(d, key, text) -> bool      string equals, no copy
data_len(d, key) -> i32            array items / object members; data_key(d, key, i, out&) the i-th member name
data_type(d, key) -> i32           DATA_MISSING, DATA_NULL, DATA_BOOL, DATA_NUMBER, DATA_STRING, DATA_ARRAY, DATA_OBJECT
data_vec(d, key, def) -> float4    an array of up to 4 numbers or an {x, y, z, w} object

Each read walks the key path, which is quick, but read a creature's numbers once into your own structs when it spawns rather than every frame.

Memory

  • Fixed arrays (var a: T[N]) are static memory: fine up to a few MB each.
  • Each growable global (var a: T[>..]) gets its own Goose data stack, stack_mb (gander.json, default 32) reserved up front. In the browser the reservation is address space, not RAM, until it is used, so growable globals are cheap in memory: do not rewrite them as fixed arrays to save RAM. The address space is not unlimited, though: the browser's heap is 2 GB in all, so gander build lowers stack_mb on the web by itself when the program has many growable globals (it prints the number it chose), and warns when a stack_mb set in gander.json would not fit. A single array that outgrows it stops the game with an error naming stack_mb. A stack that outgrows its reservation stops the game with an error naming stack_mb (an array growing without bound, or raise stack_mb). A global slot_pool or root struct spends one such stack per growable field (a slot_pool has two).
  • gander build ships GLB textures larger than the texture limit (gander.json texture_limit, or gander::texture_limit(N) in main.goose; 2048) already shrunk, so a 4K Forge or Meshy texture never decodes at full size in the browser. assets/ keeps the originals. Do not repack models by hand.
  • It also stores blendshapes sparse (only the vertices each shape moves: a talking head about halves, losslessly). "morph_normals": false in gander.json also drops the shapes' normal deltas (faces relight from the base normals; smaller again, fine for most talking heads). No need for a compaction script of your own.
  • The perf memory line: heap minus the untouched Goose stack reservations is what the game really uses; textures is an estimate of GPU memory. The perf memory lines say who holds it (Performance, above).

Areas with their own docs

Physics (GANDER physics: rigid bodies, joints, characters, ragdolls, cars with real suspension and drivetrains, cloth for capes, flags and banners), terrain and water (GANDER terrain), navigation and AI (GANDER navigation and AI: navmeshes, paths, cover, crowds of agents), UI widgets, menus and safe areas (GANDER UI), save games (GANDER saves: slots, autosave, Continue, what state to write) and open worlds of placed objects that stream in cells (GANDER streamed worlds: villages, forts, loot, NPCs over kilometres, with far scenery as cards) live in their own files next to this one; read the one you need before using it.

FPS arms

Procedural first-person arms with full hands (3 joints per finger and the thumb), drawn in the viewmodel pass (own FOV, never clips into walls). Each frame: tell the arms where the camera is, put each wrist on a grip (or set it directly), pick hand poses, draw.

arms(ArmsDesc { sleeve, glove, glove_dark, skin, sleeve_length, gloves, muscle }) -> Arms   (all fields defaulted)
arms_camera(a, cam_world: Mat4)    the camera the arms hang from (call every frame)
arms_shoulders(a, right, left, pole_right, pole_left)   shoulder and elbow-pole positions, camera space
arms_hand(a, RIGHT|LEFT, wrist: Mat4)   wrist transform (world); hand frame: -Z along the fingers,
                                   +Y back of the hand
arms_grip_define(a, name, Grip { pocket, hy, hz, round, diag, twist, thumb, skip })
                                   a grip surface the fingers close around (defaults: a rifle pistol grip)
arms_hand_on(a, side, grip, center, axis, out) -> Mat4   wrist transform that holds the named grip
                                   (center: a point on the grip in world space; axis: along the
                                   grip, either direction, the same for both hands; out: the
                                   direction the back of the hand faces)
arms_pose(a, side, name, fade)     authored hand shapes, e.g. "open", "fist", "grip", "catch"
arms_trigger(a, t)                 trigger finger 0..1 on the gun hand
arms_visible(a, on)  arms_animator(a) -> Anim   arms_draw(a)
arms_wear(a, Wear { kind, side, pattern, color, color2, from, to, loose, thick, cuff, rolled,
                    fingerless, knuckles, material }) -> i32   put on a clothing layer
arms_unwear(a, layer)              take one off; -1 takes everything off (bare arms)

The arms are anatomical: upper arm, elbow point, forearm muscles tapering to a flat wrist with its bones, palm, knuckles, nails and skin texture (muscle 0 slim .. 1 muscular). The forearm has twist bones (R_fore_twist1, R_fore_twist2), so a hand rolled palm-up twists the forearm instead of pinching the wrist. ArmsDesc's sleeve and gloves are simply the first two clothing layers; for any other outfit, take them off and dress the arms yourself. Layers stack in the order worn (the first nearest the skin), both arms unless side is RIGHT or LEFT, and follow the arm exactly, except that a glove always goes over sleeves and pads, and a band or watch on the glove's cuff goes over the glove. A sleeve under a glove cuff or a band is tucked in automatically (pressed flat there, with its folds bunched just above, and ended inside the cuff), so one loose sleeve to the wrist plus a glove is a complete sealed outfit: no inner/outer sleeve split, no tightening the sleeve by hand. Places along the arm: 0 the shoulder, 0.54 the elbow, 1 the wrist.

gander::arms_unwear(arms, -1);
// t-shirt: a short sleeve, bare forearms and hands
let a = gander::arms_wear(arms, gander::Wear { kind: gander::WEAR_SLEEVE, color: gander::hex(0x34495e), to: 0.32 });
// camo shirt rolled to below the elbow, fingerless gloves, a watch on the left wrist
let b = gander::arms_wear(arms, gander::Wear { kind: gander::WEAR_SLEEVE, pattern: gander::CLOTH_CAMO,
        color: gander::hex(0x6b7350), color2: gander::hex(0x3d3a2a), to: 0.68, rolled: true, loose: 0.5 });
let c = gander::arms_wear(arms, gander::Wear { kind: gander::WEAR_GLOVE, color: gander::hex(0x2a2a2a),
        color2: gander::hex(0x151515), fingerless: true, knuckles: true });
let d = gander::arms_wear(arms, gander::Wear { kind: gander::WEAR_WATCH, side: gander::LEFT });

Kinds: WEAR_SLEEVE (shoulder to to; loose 0 tight .. 1 baggy with folds, cuff band, rolled), WEAR_GLOVE (the hand, cuff up the forearm to from, default 0.9; fingerless; knuckles adds a hard knuckle guard and a palm patch in color2; one glove per hand), WEAR_BAND (wristband, armband, tape, bandage between from and to), WEAR_WATCH (strap plus a case on the back of the wrist, color2 the face), WEAR_PAD (a hard elbow pad, or a forearm guard placed on the forearm; color2 the straps). Patterns: CLOTH_PLAIN, CLOTH_CAMO, CLOTH_DIGITAL (both from color and color2), CLOTH_KNIT, CLOTH_LEATHER (gloves default to leather). material uses your own material (a texture) instead. Changing clothing rebuilds the arm meshes: do it at setup or on an event (putting gloves on), not every frame.

Built-in grips: grip (pistol grip), fore (a VERTICAL foregrip: only when the gun has one), handguard (a rifle's horizontal handguard), mag, knife, and for climbing bar (a round bar or rung about 3.4 cm thick: monkey bars, ladder and rope-ladder rungs, a climbing rope) and ledge (fingers hooked over the top edge of a wall, back of the hand up; its center is a point ON the edge). Before arms_grip_define, use these: they are measured against the hand. Pose the hand with the grip's name (arms_pose(a, side, "bar", 0.1)).

The axis sign does not matter: of the two ways a hand can close around a grip, the engine never returns the one that folds the wrist back on itself (knuckles toward the player, the "backwards hand"). What you choose is out, the side the back of the hand faces, and that one does matter: a reversed out puts the hand on the far side of the grip.

Rifle hold that looks right (weapon space: -Z forward, +X right, +Y up; W is the weapon's world transform, origin at the pistol grip):

// hold the gun close: hip about (0.13, -0.18, -0.30). Further out (z -0.43) the
// support arm cannot reach, locks straight and stretches into a tube
let wx = gander::transform_dir(W, v3(1.0, 0.0, 0.0));
let wy = gander::transform_dir(W, v3(0.0, 1.0, 0.0));
let rw = gander::arms_hand_on(arms, gander::RIGHT, "grip", gander::transform_point(W, v3(0.0, -0.06, 0.0)),
                              normalize(gander::transform_dir(W, v3(0.0, -1.0, 0.32))), wx);
// support hand under the handguard, about 0.30 m ahead of the pistol grip (clear of
// the magazine well), fingers forward along it; back of the hand down and out to the left
let lw = gander::arms_hand_on(arms, gander::LEFT, "handguard", gander::transform_point(W, v3(0.0, 0.04, -0.30)),
                              gander::transform_dir(W, v3(0.0, 0.0, -1.0)), normalize(wx * -1.0 - wy));
gander::arms_hand(arms, gander::RIGHT, rw);  gander::arms_pose(arms, gander::RIGHT, "grip", 0.1);
gander::arms_hand(arms, gander::LEFT, lw);   gander::arms_pose(arms, gander::LEFT, "handguard", 0.1);

Rifles look best with a vertical foregrip under the front of the handguard (draw one: a ~3 x 9 cm post, raked forward a little) and the left hand on "fore":

let fgc = v3(0.0, -0.035, -0.30);   // foregrip centre, weapon space, 0.30 m ahead of the pistol grip
let lw = gander::arms_hand_on(arms, gander::LEFT, "fore", gander::transform_point(W, fgc),
                              normalize(gander::transform_dir(W, v3(0.0, -1.0, 0.12))),   // along the post
                              normalize(wx * -1.0 + gander::transform_dir(W, v3(0.0, 0.0, -0.35))));
gander::arms_hand(arms, gander::LEFT, lw);   gander::arms_pose(arms, gander::LEFT, "fore", 0.1);

Climbing (hands follow the holds, not a weapon; arms_camera stays the player's camera). Clamp each hand to the arms' reach from its shoulder while the body catches up, or the arm locks straight:

// monkey bar / ladder rung running along bar_dir: palm forward, back of the hand
// toward the player and up a little; both hands use the same axis
let rw = gander::arms_hand_on(arms, gander::RIGHT, "bar", grip_r, bar_dir, normalize(to_player + v3(0.0, 0.3, 0.0)));
gander::arms_hand(arms, gander::RIGHT, rw);  gander::arms_pose(arms, gander::RIGHT, "bar", 0.08);
// top of a wall: center ON the far top edge (fingers hook over it), axis along the edge
let lw = gander::arms_hand_on(arms, gander::LEFT, "ledge", edge_point, edge_dir, v3(0.0, 1.0, 0.0));
gander::arms_hand(arms, gander::LEFT, lw);   gander::arms_pose(arms, gander::LEFT, "ledge", 0.08);
// crawling: no grip, set the wrist yourself (hand frame: -Z along the fingers, +Y
// the back of the hand), palm flat on the ground ahead, pose "open"

Arms reach 0.56 m from the shoulder. A wrist further out logs a GANDER-WARN ("arms: the left wrist is 0.75 m from its shoulder...") in gander test: fix it, never ship it. Check hands with a close-up from outside the gun (a test-only camera beside the weapon), not only the first-person view.

Weapon motion helpers: keep a gander::Viewmodel global per weapon, call gander::viewmodel_update(vm, gander::look_axis(), ground_speed, aiming, dt) each frame, gander::viewmodel_kick(vm, 1.0) per shot, and place the weapon with gander::mul(camera_world, gander::viewmodel_xform(vm, hip_pos, aim_pos)) (camera-space hip and aim positions, blended by aim-down-sights, plus sway, bob and recoil). Draw weapon parts with draw_model_viewmodel / draw_mesh_viewmodel / draw_animated_viewmodel so they share the arms' pass.

Recipes

First-person controller

import gander from "./.gander/gander";

var pos = float3 { 0.0, 0.0, 5.0 };
var yaw: f32 = 0.0;
var pitch: f32 = 0.0;
var ground: gander::Material;

fn main() {
    gander::config("FPS", 1280, 720, 0);
    ground = gander::material(gander::hex(0x6b7a5a), 0.0, 0.9);
    gander::touch_controls(gander::TOUCH_STICK | gander::TOUCH_LOOK, "fire,jump", "FIRE,JUMP");
}

export "gander_frame" fn frame(dt: f32) {
    if gander::mouse_pressed(gander::MOUSE_LEFT) { gander::pointer_lock(true); }
    let look = gander::look_axis();
    yaw -= gander::deg(look.x);
    pitch = clamp(pitch - gander::deg(look.y), -1.4, 1.4);
    let m = gander::move_axis();
    let fwd = gander::v3(-sin(yaw), 0.0, -cos(yaw));
    let right = gander::v3(cos(yaw), 0.0, -sin(yaw));
    let speed: f32 = if gander::action_down("sprint") { 7.0 } else { 4.0 };
    pos = pos + (fwd * m.y + right * m.x) * (speed * dt);
    let eye = pos + gander::v3(0.0, 1.7, 0.0);
    gander::camera(eye, eye + gander::forward(yaw, pitch), 75.0);
    gander::sun(gander::v3(-0.4, -1.0, -0.3), gander::WHITE, 3.0, true);
    gander::draw_mesh(gander::MESH_PLANE, ground, gander::scale(gander::v3(200.0, 1.0, 200.0)));
    gander::draw_mesh(gander::MESH_CUBE, gander::DEFAULT_MATERIAL, gander::translate(gander::v3(0.0, 0.5, 0.0)));
    gander::probe("x", pos.x);
}

Third-person character with a Meshy rig

import gander from "./.gander/gander";

var hero: gander::Model;
var anim: gander::Anim;
var pos = float3 { 0.0, 0.0, 0.0 };
var yaw: f32 = 0.0;
var cam_yaw: f32 = 0.0;

fn main() {
    gander::config("Third person", 1280, 720, 0);
    hero = gander::model("assets/models/hero.glb");
    anim = gander::animator(hero);
    gander::anim_mask(anim, 1, "Spine");        // layer 1 = upper body
}

export "gander_frame" fn frame(dt: f32) {
    cam_yaw -= gander::deg(gander::look_axis().x);
    let m = gander::move_axis();
    let dir = gander::v3(-sin(cam_yaw), 0.0, -cos(cam_yaw)) * m.y + gander::v3(cos(cam_yaw), 0.0, -sin(cam_yaw)) * m.x;
    let moving = length(dir) > 0.1;
    if moving {
        pos = pos + dir * (4.5 * dt);
        yaw = atan2(-dir.x, -dir.z);
        gander::play(anim, "run");
    } else {
        gander::play(anim, "idle");
    }
    if gander::action_pressed("fire") { gander::anim_play(anim, "shoot", 1, false, 0.1, 1.0); }
    let target = pos + gander::v3(0.0, 1.4, 0.0);
    gander::camera(target - gander::forward(cam_yaw, -0.3) * 5.0, target, 60.0);
    gander::sun(gander::v3(-0.4, -1.0, -0.3), gander::WHITE, 3.0, true);
    // Meshy characters face +Z: add pi to the yaw.
    gander::draw_animated(anim, gander::place(pos, yaw + 3.14159));
}

Top-down 2D with a sprite sheet

import gander from "./.gander/gander";

var sheet: gander::Texture;     // 4 columns of walk frames
var p = float2 { 160.0, 90.0 };
var walk: f32 = 0.0;
var left = false;

fn main() {
    gander::config("Top down", 320, 180, gander::PIXEL_ART);
    sheet = gander::texture("assets/sprites/hero_walk.png");
    gander::touch_controls(gander::TOUCH_STICK, "interact", "USE");
}

export "gander_frame" fn frame(dt: f32) {
    let m = gander::move_axis();
    p = p + float2 { m.x, -m.y } * (70.0 * dt);       // 2D +y is down
    if length(m) > 0.1 { walk += dt; } else { walk = 0.0; }
    if m.x < -0.1 { left = true; }
    if m.x > 0.1 { left = false; }
    gander::camera2d(floor(p.x), floor(p.y), 1.0, 0.0);
    let f = ((walk * 10.0) as! i32) % 4;
    gander::sprite(sheet, gander::Sprite { x: floor(p.x), y: floor(p.y), frame: f, cols: 4,
                                       flags: if left { gander::FLIP_X } else { 0 } });
    gander::ui();
    gander::text("USE: E", 6.0, 4.0, 12.0, gander::WHITE);
}

Platformer physics (hand-rolled, axis-aligned boxes)

import gander from "./.gander/gander";

struct Box { x: f32, y: f32, w: f32, h: f32 }
var solids: Box[>..] = [];
var px: f32 = 100.0;
var py: f32 = 100.0;
var vx: f32 = 0.0;
var vy: f32 = 0.0;
var grounded = false;

fn hits(x: f32, y: f32) -> bool {
    for b in solids {
        if x + 12.0 > b.x && x < b.x + b.w && y + 16.0 > b.y && y < b.y + b.h { return true; }
    }
    false
}

fn main() {
    gander::config("Platformer", 640, 360, gander::KEEP_ASPECT);
    solids.push(Box { x: 0.0, y: 320.0, w: 640.0, h: 40.0 });
    solids.push(Box { x: 220.0, y: 250.0, w: 120.0, h: 16.0 });
    gander::touch_controls(gander::TOUCH_STICK, "jump", "JUMP");
}

export "gander_frame" fn frame(dt: f32) {
    vx = gander::move_axis().x * 160.0;
    if grounded && gander::action_pressed("jump") { vy = -380.0; }
    vy = min(vy + 980.0 * dt, 600.0);
    if !hits(px + vx * dt, py) { px += vx * dt; }
    grounded = false;
    if hits(px, py + vy * dt) {
        if vy > 0.0 { grounded = true; }
        vy = 0.0;
    } else {
        py += vy * dt;
    }
    for b in solids { gander::rect(b.x, b.y, b.w, b.h, gander::hex(0x4a5a6a)); }
    gander::rect(px, py, 12.0, 16.0, gander::YELLOW);
    gander::probe("grounded", if grounded { 1.0 } else { 0.0 });
}

For anything beyond this, the physics area doc (GANDER physics) has real rigid bodies, character controllers, ragdolls, vehicles (vehicle_car: suspension, engine, gearbox, tyre grip) and cloth (cloth: capes, flags, banners, curtains in the wind).

Music, sound effects and a HUD

import gander from "./.gander/gander";

var coin: gander::Sound;
var score: i32 = 0;
var title: gander::Font;

fn main() {
    gander::config("Audio + HUD", 1280, 720, 0);
    coin = gander::sound("assets/audio/sfx/coin.mp3");
    title = gander::font("assets/fonts/title.ttf");
    gander::music("assets/audio/music/gameplay.mp3", 0.6, 2.0);
}

export "gander_frame" fn frame(dt: f32) {
    if gander::action_pressed("confirm") {
        score += 1;
        gander::sfx(coin);
    }
    var s: u8[>..] = [];
    format(s, "SCORE ", score);
    gander::text_shadow(title, s, gander::width() - 24.0, 20.0, 40.0, gander::WHITE, gander::ALIGN_RIGHT);
    gander::text_center("PRESS ENTER", gander::width() * 0.5, gander::height() * 0.5, 32.0, gander::YELLOW);
}

A simple state machine (title, play, game over)

import gander from "./.gander/gander";

enum Scene { Title, Play, Over }
var scene = Scene.Title;
var timer: f32 = 0.0;

fn main() { gander::config("States", 1280, 720, 0); }

export "gander_frame" fn frame(dt: f32) {
    let cx = gander::width() * 0.5;
    match scene {
        Title => {
            gander::text_center("MY GAME", cx, 240.0, 72.0, gander::WHITE);
            if gander::action_pressed("confirm") { scene = Scene.Play; timer = 0.0; }
        },
        Play => {
            timer += dt;
            gander::text_center("PLAYING", cx, 40.0, 32.0, gander::WHITE);
            if timer > 10.0 { scene = Scene.Over; }
        },
        Over => {
            gander::text_center("GAME OVER", cx, 240.0, 72.0, gander::RED);
            if gander::action_pressed("confirm") { scene = Scene.Title; }
        },
    }
}

The gander command

Run from the game directory (or pass it). Every command takes --json.

gander new [dir] --template 3d|2d     start a game (only when there is no main.goose)
gander check                          typecheck + engine checks + missing assets, ~1 s; file:line:col errors
gander build [--debug]                web build into build/web (--debug adds overflow / bounds
                                    checks that name the line, slower)
gander test [options]                 play build/web in headless Chromium, fixed 1/60 s steps
    --frames N                      frames to run (default 120)
    --shots 1,60,120                frames to screenshot -> /tmp/gander-test/frame-NNNNN.png
    --input "EVENTS"                scripted input, comma separated FRAME:ACTION:ARGS
                                      30:hold:w:60        hold W for 60 frames from frame 30
                                      90:press:space      tap a key (letters, digits, space,
                                                          enter, escape, tab, arrows, shift, ctrl, alt)
                                      100:look:300:0      mouse-look delta in pixels
                                      120:click:640:360   click at logical 2D coordinates
                                      150:touch:100:600:40  a touch held 40 frames
    --mobile                        390x844 phone viewport with touch
    --size WxH                      exact screenshot size (store shots: 1920x1080, 1290x2796, 2064x2752)
    --clean                         presentation mode: gander::is_capture() is true
    --native                        run build/native (gander build --target native): ~25x faster
    --backend webgpu|webgl2         force one web backend (default: what the page picks, WebGPU when
                                      the browser has it); both builds come from one gander build
    --gpu                           the web test in Chromium on a GPU machine (when the turn has GPU
                                      renders): real frame rates for --perf, fast 3D play-tests;
                                      up to 36000 frames (10 minutes of game time)
    --opt NAME=VALUE                launch option the game reads with launch_option() (repeatable)
    --save-out FILE                 when the run ends, every key the game saved (slots, save()) as JSON
    --save-in FILE                  start with those saves (a --save-out file): chain runs
    --timeout S                     --native / --gpu: limit for the whole run (default from --frames,
                                      3x game time + 60 s); web: the wait for the start and each shot
    --out DIR                       where shots, log.txt and state.json go
gander capture --out clip.mp4         gameplay video: exact --size, fixed --fps, scripted --input,
    [--seconds S --fps 30 --size WxH]   the game's own audio in sync; --mute-music for trailers;
    [--input "EVENTS" --mute-music] native by default, --web falls back to the browser (no audio)
gander serve [--port 8080]            serve build/web locally (only if a human asks)

A test draws only the frames it screenshots and its last frame; the frames in between run the game but skip the GPU, so --frames 600 costs little more than --frames 60. --perf times the game logic on every frame and draws every eighth (the software GPU takes seconds per 3D frame), so a 300-frame perf run takes a few minutes on a big 3D scene. A crashed browser tab is reported as an error (memory, almost always); gander test --native then shows whether the game itself crashes.

Every test starts with no saves (a fresh browser profile; natively a fresh save folder, never your own). To test past one run, or to split a long scripted play-through into parts, chain runs through a save:

gander test --frames 9000 --input "..." --save-out /tmp/pilot-1.json
gander test --frames 9000 --input "..." --save-in /tmp/pilot-1.json --opt continue=1

Run 1 must leave a save behind: the game autosaves (save_end(0, ...)), or the input reaches a save point. Run 2 loads the keys before the game starts; the engine does not continue by itself, so the game reads the option and loads the newest save, or the input taps Continue on the menu:

var opt: u8[>..] = [];
if gander::launch_option("continue", opt) && gander::save_latest() >= 0 {
    if gander::save_open(gander::save_latest()) { read_save(); }
}

The same file works on the web, --mobile, --native and --gpu.

gander test fails on engine errors, Goose panics and gander::fail. Warnings (GANDER-WARN: bad handles, unknown actions, missing clips) are printed: read them, they are almost always real bugs. Probes come back in state.json and on the last line of output: probe the numbers that prove the feature works (score, position, health, state).