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 inmain(), 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 loadsplayer.goosebeside it; its functions and globals are then visible everywhere (no namespace unless the file declares one). Only main.goose has theimport gander from "./.gander/gander";line. Other files may writeimport gander;or nothing; the package is visible program-wide once the root declares it. Call it asgander::nameeverywhere. - 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 arefloat4. Matrices aregander::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: useas!((t * 10.0) as! i32truncates toward zero, like C) or cast an exact value (floor(x) as i32,round(x) as i32). A plainasfrom a float with a fraction aborts ingander build --debugbuilds ("as conversion changes the value"); colour bytes the same:(c * 255.0) as! u8. - Some everyday words are keywords:
padcannot be a field or variable name (it is struct padding). Pick another (margin,gap). asonly converts numbers.flag as f32on a bool is an error; writeif flag { 1.0 } else { 0.0 }.ifis 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 enemiescopies, and writing to the copy is a compile error.for e, i in xsgives the index (an i64). - Ranges:
for i: i32 in 0..10 { }(half open). Without the type, i is an i64. .lenis a property, not a call:xs.len(an i64;xs.len as i32).xs[f()]wherefpushes ontoxsfails ("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 analive: boolflag (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 { ... } }itemskeeps freed slots (items.lenis the high-water count and the loop visits them), so keep thealiveflag and clear it beforefree. root struct Level { enemies: Enemy[>..] = [], shots: Shot[>..] = [], wave: i32 = 1 }holds several growable arrays in one value (var lv = Level { .. };, global or local, passed asLevel&); 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
u8arrays. Literals areconst 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 freshu8[>..].print(...)writes to the log.stdhas find, split, trim, parse_int, parse_uint, andsplit_once(s, sep)(before, after, found:let k, v, ok = split_once(line, "=");), plusascii::is_digit is_alpha is_alnum hex_value. - No closures, no function pointers, no classes, no exceptions, no null by
default. Use enums with
matchfor state machines: declareenum Scene { Title, Play, Over }, build values asScene.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 asHit 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; }andlet v, ok = f();. constandletglobals 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 > 300of au8). Handles areu32ids, sotex.id >= 0means nothing: a handle that was never loaded istex.id == 0. - Math:
sin cos tan atan2 sqrt pow exp floor min max clamp abs lerpwork on f32 and f64 (stdandmathcome with the gander package), and so doradians degrees is_nan is_inf is_finite(an f32 angle gives an f32 back, noas f32). An integer argument needs a width:radians(90.0), notradians(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 constantsPI 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 globalINFis "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 declaredrecursive fn"): writerecursive fn kill(...)on the function the message names. deferruns 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 itselfreturn,breakorcontinue.- Not allowed in gander games:
externdeclarations (the publish refuses them) andthread_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(...), constantsWHITE 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 readgander::width()for layout, never assume 1280). Withgander::KEEP_ASPECTorgander::PIXEL_ARTthe 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::EXPANDfills the screen with no bars at all: the height stays the config height and the width follows the aspect, so lay out fromgander::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. Callingconfig()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_beginclears tocanvas_clear's colour (transparent by default). Withcanvas_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'ssunandshaftsas real light through the fog, shadowed by trees, slats, windows and ridges (needsfogfor 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
maxor 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_modelcosts per point, not per vertex (three corners skinned on the CPU); blendshapes are not applied to the points.anim_dissolveburns the animator's skinned meshes; a rigid part of the same model (a helm node) needsmaterial_dissolveon 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, sogander buildlowers 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 globalslot_poolorroot structspends one such stack per growable field (a slot_pool has two). gander buildships GLB textures larger than the texture limit (gander.jsontexture_limit, organder::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": falsein 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 memorylines 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).