# GANDER engine reference > GANDER is vbgnt's own game engine, built for an AI agent to write games in. Games are written in Goose, compiled ahead of time to C and then to WebAssembly, and run on WebGPU or WebGL2 in the browser, with native builds for Windows, macOS, Linux, Android and iOS. This is the reference the agent works from, published as it is. # GANDER 0.1 API sheet 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 ```goose import gander from "./.gander/gander"; // root file only; .gander is managed by the gander tool var x: f32 = 0.0; // game state lives in globals fn main() { // runs once: config, then load everything gander::config("My Game", 1280, 720, 0); } export "gander_frame" fn frame(dt: f32) { // runs every frame; dt in seconds x += 60.0 * dt; gander::rect(x, 100.0, 40.0, 40.0, gander::RED); } ``` - Immediate mode: draw everything you want visible every frame. Nothing persists between frames except settings (camera, sun, sky, fog, post) and loaded resources. - Resources (`gander::Texture`, `Model`, `Mesh`, `Material`, `Sound`, `Font`, `Anim`, `Arms`, `Face`) are small handles. Load once in `main()`, keep them in globals. A failed load returns handle 0 and logs a warning; drawing with it warns and draws nothing. - Other modules: `import player;` in main.goose loads `player.goose` beside it; its functions and globals are then visible everywhere (no namespace unless the file declares one). Only main.goose has the `import gander from "./.gander/gander";` line. Other files may write `import gander;` or nothing; the package is visible program-wide once the root declares it. Call it as `gander::name` everywhere. - Vectors are the standard library's `float2`, `float3`, `float4` (global names, fields x y z w, `+ - * /` elementwise and by a scalar, `dot`, `cross`, `length`, `normalize`, `distance`, `lerp`). Quaternions are `float4`. Matrices are `gander::Mat4` (column major). ## Goose for agents: what actually bites - **Float literals are f64.** `let a = i * 0.5;` is an f64 and will not pass where f32 is wanted. Annotate: `let a: f32 = i * 0.5;`. Integers widen to floats implicitly; narrowing needs a cast. **Float to int: use `as!`** (`(t * 10.0) as! i32` truncates toward zero, like C) or cast an exact value (`floor(x) as i32`, `round(x) as i32`). A plain `as` from a float with a fraction aborts in `gander build --debug` builds ("as conversion changes the value"); colour bytes the same: `(c * 255.0) as! u8`. - Some everyday words are keywords: `pad` cannot be a field or variable name (it is struct padding). Pick another (`margin`, `gap`). - `as` only converts numbers. `flag as f32` on a bool is an error; write `if flag { 1.0 } else { 0.0 }`. - `if` is an expression: `let s = if fast { 8.0 } else { 4.0 };` (both arms must have the same type). - **Mutating array elements needs `&`:** `for &e in enemies { e.hp -= 1; }`. `for e in enemies` copies, and writing to the copy is a compile error. `for e, i in xs` gives the index (an i64). - Ranges: `for i: i32 in 0..10 { }` (half open). Without the type, i is an i64. - `.len` is a property, not a call: `xs.len` (an i64; `xs.len as i32`). - **`xs[f()]` where `f` pushes onto `xs` fails** ("index 11 out of bounds (length 11)", reads and writes alike): Goose reads the array's length before it calls the index expression. Split it: `let i = f(); let m = xs[i];` (a find-or-load helper that appends: `kit_models[kit_by_name("SM_Torch")]`). - Arrays: `var xs: Enemy[>..] = [];` is a growable array; `xs.push(v)` returns a reference to the new element. Fixed: `f32[16]`, literal `[0.0; 16]`. Remove by rebuilding or keep an `alive: bool` flag (cheapest). - Things that spawn and die all the time (bullets, sparks, pickups) fit a `slot_pool`, which hands freed slots back out instead of growing: ```goose import slot_pool; var shots = slot_pool {}; let i = shots.alloc_index(Shot { pos: p, alive: true }); // or alloc_ref(v) -> Shot& shots.items[i].alive = false; shots.free(i); // free_ref(s) frees by reference for &s in shots.items { if s.alive { ... } } ``` `items` keeps freed slots (`items.len` is the high-water count and the loop visits them), so keep the `alive` flag and clear it before `free`. - `root struct Level { enemies: Enemy[>..] = [], shots: Shot[>..] = [], wave: i32 = 1 }` holds several growable arrays in one value (`var lv = Level { .. };`, global or local, passed as `Level&`); it cannot be copied, returned or stored in an array. `dst.move_from(src)` copies between equal-length slices even when they overlap: `xs[1..n].move_from(xs[0..n - 1])` shifts a list along by one. - Structs: `struct E { pos: float3, hp: i32 = 100 }`. Literals name fields (`E { pos: p }`, hp takes its default) or list them all positionally; `E { pos: p, .. }` zeroes everything not given. - Strings are `u8` arrays. Literals are `const u8[:]` slices. Build text with a builder: ```goose fn hud_line(score: i32, hp: f32) { var s: u8[>..] = []; format(s, "SCORE ", score, " HP ", hp as! i32); gander::text(s, 20.0, 20.0, 28.0, gander::WHITE); } ``` `str(a, b, ...)` builds a fresh `u8[>..]`. `print(...)` writes to the log. `std` has find, split, trim, parse_int, parse_uint, and `split_once(s, sep)` (before, after, found: `let k, v, ok = split_once(line, "=");`), plus `ascii::is_digit is_alpha is_alnum hex_value`. - No closures, no function pointers, no classes, no exceptions, no null by default. Use enums with `match` for state machines: declare `enum Scene { Title, Play, Over }`, build values as `Scene.Play`, and match with bare variant names, one comma after each arm: `match scene { Title => { ... }, Play => { ... }, Over => { ... }, }`. Variants may carry data (`Hit { damage: i32 }`, matched as `Hit h => ...`); a match must cover every variant or end with `_ => ...`. - Functions overload by type; untyped parameters make generics. - Multiple return values: `fn f() -> f32, bool { return 1.0, true; }` and `let v, ok = f();`. - `const` and `let` globals with literal values are compile-time constants. - Every global needs an initial value or a type (`var hero: gander::Model;` starts as handle 0 until main() assigns it). - References cannot outlive what they point at; the compiler says so with the line. Copy values out instead of keeping references in globals. - A comparison its type already decides warns (`comparison is always true: u32 values are 0 to 4294967295`), and one the type cannot hold is an error (`b > 300` of a `u8`). Handles are `u32` ids, so `tex.id >= 0` means nothing: a handle that was never loaded is `tex.id == 0`. - Math: `sin cos tan atan2 sqrt pow exp floor min max clamp abs lerp` work on f32 and f64 (`std` and `math` come with the gander package), and so do `radians degrees is_nan is_inf is_finite` (an f32 angle gives an f32 back, no `as f32`). An integer argument needs a width: `radians(90.0)`, not `radians(90)`. `is_finite(v)` is the cheap guard before using a physics or division result. Also at both widths: `fract smoothstep(e0, e1, x) inverse_lerp(a, b, v) wrap_angle` (radians into [-PI, PI)), `hypot cbrt exp2 sinh cosh tanh copysign`, and the constants `PI TAU E INF NAN`. - **Do not write your own helper under a std or math name** (`wrap_angle`, `smoothstep`, `fract`, `INF`...): a same-typed copy makes every call "ambiguous call to wrap_angle", and a global `INF` is "global name already declared". Delete your copy and use the built-in one, or rename it (`SCORE_INF`). - A function that calls itself, directly or through others (`damage` -> `kill` -> `damage`), is an error ("recursive call cycle through kill, which is not declared `recursive fn`"): write `recursive fn kill(...)` on the function the message names. - `defer` runs a statement when its block ends, at every exit (fall-through, `break`, `continue`, `return`), latest first: `gander::perf_begin("ai"); defer gander::perf_end("ai");` times the whole function however it returns. Its body cannot itself `return`, `break` or `continue`. - Not allowed in gander games: `extern` declarations (the publish refuses them) and `thread_fn` (no threads on the web). ## Conventions - **Colors** are sRGB 0..1 like CSS: `gander::hex(0xff8040)`, `gander::rgb(1.0, 0.5, 0.2)`, `gander::rgba(...)`, constants `WHITE BLACK RED GREEN BLUE YELLOW CLEAR`, `with_alpha(c, a)`, `mix(a, b, t)`. - **3D**: meters, +Y up, -Z forward (glTF). Characters face -Z when yaw = 0. - **2D**: logical pixels, origin top left, +Y down. The logical height is the `config()` height; the width follows the window's aspect (so read `gander::width()` for layout, never assume 1280). With `gander::KEEP_ASPECT` or `gander::PIXEL_ART` the size is exactly the config size, scaled as large as the window allows and letterboxed only on the mismatched axis (pixel art stays crisp at any scale; the engine resamples it, never draws it small). `gander::PIXEL_ART | gander::EXPAND` fills the screen with no bars at all: the height stays the config height and the width follows the aspect, so lay out from `gander::width()` and let backgrounds run past the play area. Use it for pixel games meant for phones and any game that should fill a browser. `gander::EXPAND | gander::KEEP_ASPECT` (with or without PIXEL_ART) always shows the whole config box and grows the canvas on the other axis: centre a fixed layout at ((width() - W) / 2, (height() - H) / 2) and draw your own surround (a tiled wall, a bezel, the title) in the spare room. Calling `config()` again at runtime switches the box, e.g. 640x360 when the window is wider than tall and 360x640 otherwise (leave out the orientation flags). - 2D draws in the order you submit them within a layer; `gander::layer(z)` sorts (higher on top). 2D always draws over 3D. - Angles are radians; `gander::deg(90.0)` converts. Field of view is degrees. ## App ```text 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: https://vbgnt.com/docs/gander/saves.md 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. ```text 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: ```text 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 ```text 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: ```goose 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. ```goose // main() trk = gander::canvas_texture(256, 256); gander::canvas_clear(trk, gander::hex(0x02140a)); trk_glass = gander::material(gander::WHITE, 0.0, 0.3); gander::material_texture(trk_glass, gander::SLOT_BASE, trk); gander::material_flags(trk_glass, false, true); // unlit: the screen shows its own light // frame(): only while the tracker is on screen gander::canvas_begin(trk); gander::ring(128.0, 128.0, 120.0, 2.0, green); gander::line(128.0, 128.0, 128.0 + sin(a) * 118.0, 128.0 - cos(a) * 118.0, 3.0, green); gander::text(gander::DEFAULT_FONT, "12 M", 128.0, 212.0, 26.0, green, gander::ALIGN_CENTER); gander::canvas_end(); gander::draw_mesh(gander::MESH_PLANE, trk_glass, screen_xf); ``` - A canvas begun this frame is redrawn before the 3D passes, so the materials that show it see this frame's picture. A canvas you do not begin keeps its last picture and costs nothing: draw static text once, redraw live readouts every frame they change. - `canvas_begin` clears to `canvas_clear`'s colour (transparent by default). With `canvas_keep(t, true)` it draws over the last picture instead: a phosphor trail is a translucent dark rect over the whole canvas first. - For 3D it is used as colour (base or emissive slot): the engine keeps a mipmapped copy with straight alpha, made the first time a material uses it, so a screen stays steady at a distance. Its UVs are the image's (v = 0 at the top), like a loaded image. - Several canvases per frame are fine (16 at most). A canvas can show another canvas as a sprite (the one made earlier is drawn first); never itself. It is for display: UI widgets drawn into it do not take input. - Cost: one small 2D pass per canvas you began, plus a copy and its mip levels when a material shows it (256 x 256: well under 0.1 ms of GPU). ```text 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). ```goose 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. ```goose 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 ```goose 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. ```goose let jet = gander::fog_volume(gander::v3(1.4, 1.2, -15.0), gander::v3(1.0, 2.8, 1.0), gander::hex(0xb8c4d0), 2.5, 1.5); gander::fog_volume_scatter(jet, 0.6, 0.95, 0.6); // a dark zone: thick murk while the camera is inside, the torch barely lighting the air gander::fog_zone(gander::v3(0.0, 1.5, -56.0), gander::v3(4.4, 3.2, 12.0), gander::FogZone { density: 0.09, extinction: 1.5, albedo: 0.3, phase: 0.2, lamps: 0.12 }, 4.0); ``` `fog_zone` blends in over `margin` metres while the camera is in the box (the smallest zone holding it wins, like `post_zone`): it replaces `fog_scatter`'s values, `fog()`'s density (when `density >= 0`) and colour (when `color` alpha > 0), and multiplies every light's `fog` by `lamps`. Up to 16 zones. ### Pro graphics (desktop exports) Native desktop builds (macOS, Windows, Linux exports and `gander test --native` on a Mac) render Pro graphics by default; the web and phone builds keep the standard look, so the game itself needs no changes. Pro adds: - temporal anti-aliasing instead of MSAA: no shimmer on thin poles, wires, fences and foliage edges, also in motion - ambient occlusion: corners, contact points and the ground under objects darken (sky light only, so sunlit areas stay bright) - light bounced off nearby surfaces: a sunlit wall warms the floor next to it, torches and glowing signs tint what is around them - reflections of the scene itself in glossy floors, water and metal (surfaces with roughness under about 0.5: `material(color, metallic, 0.1)` is a polished floor) - `fog_light`'s `sun` and `shafts` as real light through the fog, shadowed by trees, slats, windows and ridges (needs `fog` for the haze; shafts alone light the clear air a little) The ambient occlusion and bounced light come from the depth the frame already drew (no second pass over the scene's geometry, characters included) and reach the next frame's lighting: contact shadows under something moving trail it by one frame. ```goose 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. ```goose 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) | ```text 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. ```goose 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 ```text 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`. ```goose // 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: ```goose // 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). ```goose // 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: ```goose // 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. ```goose 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 } ``` ```text 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). ```goose breath = gander::emit_attached(frost, gargoyle, "Head", gander::v3(0.0, 0.05, 0.12), gander::v3(0.0, -0.3, 1.0), 40.0); // a creature dying: 0.6 s after the death clip starts, burn it over 1.6 s let burn = clamp((t - died_at - 0.6) / 1.6, 0.0, 1.0); gander::anim_dissolve(c.anim, burn, gander::hex(0xff7a2a)); // edge colour; alpha 0 = no glow if burn > 0.0 && burn < 1.0 { gander::emit_from_model(ash, c.anim, c.xf, 40); gander::emit_from_model(embers, c.anim, c.xf, 6); } if burn < 1.0 { gander::draw_animated(c.anim, c.xf); } // gone at 1: stop drawing it ``` Presets (return a `Particles` to emit as is or change): `fx_fire fx_smoke fx_embers fx_sparks fx_muzzle fx_dust fx_steam fx_blood fx_debris fx_rain fx_snow fx_shockwave`. Combine them: an explosion is `fx_muzzle` (flash, scaled up) + `fx_debris` + `fx_dust` + `fx_shockwave` + a short bright `light`; a campfire is `fx_fire` + `fx_embers` + `fx_smoke` + a flickering `light`. `Particles` fields: `look` (`FX_SOFT FX_SMOKE FX_SPARK FX_FLAME FX_DROP FX_CHUNK FX_RING FX_STAR`) or your own `texture` (a flipbook sheet with `cols` / `rows` plays over each particle's life), `additive` (glows) or alpha blended, `lit` (alpha particles take the scene's sun, shadows and lamps), `life`, `speed`, `spread` (0 jet .. 1 hemisphere .. 2 all ways), `radius` (emit from a ball), `gravity` (negative rises), `drag`, `wind`, `size` / `size_end`, `color` / `color_end` (alpha 0 fades out), `glow` (above 1 blooms), `spin`, `stretch` (streaks along the velocity: sparks, rain), `bounce` + `floor_y`, `flat` (lies flat: ripples, ground rings), `max`, and `*_jitter` for variation. - Rain and snow: emit around the camera each frame (`emit_rate(rain, camera_pos() + v3(0, 8, 0), v3(0, -1, 0), 2500.0)`). - Budget: about 24,000 live particles in total on desktop, 8,000 on phones; emits past a system's `max` or the total are dropped. Big soft puffs cost fill rate: prefer fewer, larger smoke particles. - Particles show in the main view only (not in render views), cast no shadows, and don't collide with anything but their `floor_y`. - `emit_from_model` costs per point, not per vertex (three corners skinned on the CPU); blendshapes are not applied to the points. `anim_dissolve` burns the animator's skinned meshes; a rigid part of the same model (a helm node) needs `material_dissolve` on its material. ## Decals Marks painted onto whatever is there: bullet holes, blood, scorch, cracks, stains, paint, footprints. Put one where a raycast hit, facing out along the hit normal; it wraps over corners, steps and uneven terrain. Never fake marks with thin quads or flattened cubes (they float, z-fight and hang off edges). ```goose 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"); ``` ```text 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 ```text 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. ```goose 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: ```text 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: ```goose 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); ``` ```text 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 (https://vbgnt.com/docs/gander/terrain.md), 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 ```text 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. ```goose // 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. ```goose // 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 ```text 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). ```goose // 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 ```text 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 ``` ```text 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. ```goose 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: ```goose 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: ```goose // 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. ```goose 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. ```text 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 ``` ```goose // 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. ```goose // 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 ```text 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. ```goose // 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. ```goose 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. ```text 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) ``` ```goose 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. ```text 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 ``` ```goose 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. ```text 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. ```goose 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 ``` ```text 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. ```goose 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} ``` ```text data(path) -> Data parse once (the same path again returns the same handle); a bad file warns with its line and reads as empty data_parse(text) -> Data JSON the game holds (a network message) data_free(d) keys "stats.hp", "moves.2.name", "moves[2].name", "" the root data_f / data_num / data_int / data_bool (d, key, default) numbers (bools read as 0 / 1) data_str(d, key, out&) -> bool appends the text (numbers and bools as text) data_is(d, key, text) -> bool string equals, no copy data_len(d, key) -> i32 array items / object members; data_key(d, key, i, out&) the i-th member name data_type(d, key) -> i32 DATA_MISSING, DATA_NULL, DATA_BOOL, DATA_NUMBER, DATA_STRING, DATA_ARRAY, DATA_OBJECT data_vec(d, key, def) -> float4 an array of up to 4 numbers or an {x, y, z, w} object ``` Each read walks the key path, which is quick, but read a creature's numbers once into your own structs when it spawns rather than every frame. ## Memory - Fixed arrays (`var a: T[N]`) are static memory: fine up to a few MB each. - Each growable global (`var a: T[>..]`) gets its own Goose data stack, `stack_mb` (gander.json, default 32) reserved up front. In the browser the reservation is address space, not RAM, until it is used, so growable globals are cheap in memory: do not rewrite them as fixed arrays to save RAM. The address space is not unlimited, though: the browser's heap is 2 GB in all, so `gander build` lowers stack_mb on the web by itself when the program has many growable globals (it prints the number it chose), and warns when a stack_mb set in gander.json would not fit. A single array that outgrows it stops the game with an error naming stack_mb. A stack that outgrows its reservation stops the game with an error naming stack_mb (an array growing without bound, or raise stack_mb). A global `slot_pool` or `root struct` spends one such stack per growable field (a slot_pool has two). - `gander build` ships GLB textures larger than the texture limit (gander.json `texture_limit`, or `gander::texture_limit(N)` in main.goose; 2048) already shrunk, so a 4K Forge or Meshy texture never decodes at full size in the browser. assets/ keeps the originals. Do not repack models by hand. - It also stores blendshapes sparse (only the vertices each shape moves: a talking head about halves, losslessly). `"morph_normals": false` in gander.json also drops the shapes' normal deltas (faces relight from the base normals; smaller again, fine for most talking heads). No need for a compaction script of your own. - The perf memory line: heap minus the untouched Goose stack reservations is what the game really uses; textures is an estimate of GPU memory. The `perf memory` lines say who holds it (Performance, above). ## Areas with their own docs Physics (https://vbgnt.com/docs/gander/physics.md: rigid bodies, joints, characters, ragdolls, cars with real suspension and drivetrains, cloth for capes, flags and banners), terrain and water (https://vbgnt.com/docs/gander/terrain.md), navigation and AI (https://vbgnt.com/docs/gander/ai.md: navmeshes, paths, cover, crowds of agents), UI widgets, menus and safe areas (https://vbgnt.com/docs/gander/ui.md), save games (https://vbgnt.com/docs/gander/saves.md: slots, autosave, Continue, what state to write) and open worlds of placed objects that stream in cells (https://vbgnt.com/docs/gander/world.md: 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. ```text 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. ```goose 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): ```goose // 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": ```goose 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: ```goose // 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 ```goose 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 ```goose 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 ```goose 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) ```goose 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 (https://vbgnt.com/docs/gander/physics.md) 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 ```goose 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) ```goose 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`. ```text 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: ```text 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: ```goose 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). # gander physics 2D physics (Box2D v3), 3D physics (Jolt), a walking character controller, ragdolls and cloth. Everything is in `gander::` (module `physics.goose`, part of the package). Bodies, joints, characters, ragdolls and cloth are handles: `Body2`, `Joint2`, `Body`, `Joint`, `Character`, `Ragdoll`, `Cloth` (`.id == 0` is none). ## Rules that bite - **Physics only moves when you step it.** Call `gander::physics2d_step(dt)` (2D) and/or `gander::physics_step(dt)` (3D) once per frame, before reading positions and before drawing. Both run fixed 1/60 s ticks inside, so the simulation is frame-rate independent and deterministic in `gander test`. - **Physics does not draw.** Each frame, read the body's position and draw your sprite / mesh / model there (`gander::body_xform(b)` is a ready `Mat4` for 3D; it has no scale, so multiply your mesh scale on the right: `gander::mul(gander::body_xform(b), gander::scale(size))`). - Shapes are created **centered**. `body_box` takes the full width/height in 2D but **half extents** in 3D. - Kinds: `gander::STATIC` (never moves), `gander::DYNAMIC` (simulated), `gander::KINEMATIC` (moved by you, pushes dynamic bodies). - Contact / sensor events cover only the latest step call: read them right after stepping, every frame. - `gander::physics_debug_draw()` (3D) draws every body's bounds: green awake, grey asleep. Use it when something falls through the floor. ## 2D (pixels, +Y down, radians clockwise) Scale: 32 px = 1 m (`physics2d_scale(px)`), gravity 9.8 m/s^2 down (`physics2d_gravity(x, y)` in px/s^2). Defaults for new shapes: `physics2d_defaults(density, friction, bounce)` (1, 0.6, 0). | Create | | | --- | --- | | `body2d_box(x, y, w, h, kind) -> Body2` | center + full size | | `body2d_circle(x, y, r, kind)` | | | `body2d_capsule(x, y, h, r, kind)` | standing capsule, total height h (characters) | | `body2d_polygon(x, y, points: f32[], kind)` | convex, 3..8 points (x0, y0, x1, y1...) relative to (x, y) | | `body2d_chain(points: f32[], looped)` | static ground polyline in world pixels (hills, caves), at least 4 points | | `body2d_sensor(x, y, w, h)` | static trigger area | | `body2d_add_box(b, ox, oy, w, h, angle)`, `body2d_add_circle(b, ox, oy, r)` | compound shapes | | `body2d_destroy(b)`, `body2d_valid(b)` | | State: `body2d_pos(b) -> float2`, `body2d_angle`, `body2d_vel -> float2` (px/s), `body2d_spin` (rad/s), `body2d_set_pos/_set_angle/_set_vel/_set_spin`, `body2d_impulse(b, ix, iy)`, `body2d_impulse_at(b, ix, iy, px, py)`, `body2d_force`, `body2d_torque`, `body2d_mass(b) -> f32`. Behavior: `body2d_fixed_rotation(b, true)` (characters), `body2d_gravity_scale`, `body2d_damping(b, linear, angular)`, `body2d_bullet(b, true)` (fast objects), `body2d_friction`, `body2d_bounce`, `body2d_density`, `body2d_filter(b, category_bits, mask_bits)`, `body2d_tag(b, i32)` / `body2d_get_tag(b)`, `body2d_on_ground(b) -> bool` (a contact pushing up from below within ~45 degrees). Events and queries: - `physics2d_contact_count()`, `physics2d_contact(i) -> Contact2 { a, b, x, y, nx, ny, began }` (began false: they separated; normal points from a to b). - `physics2d_sensor_count()`, `physics2d_sensor_event(i) -> Contact2` (a = the sensor, b = the visitor). - `raycast2d(x1, y1, x2, y2, mask) -> Hit2 { body, x, y, nx, ny, fraction }` (`body.id == 0`: no hit; mask 0 = all). - `physics2d_overlap_box(x, y, w, h) -> count`, `physics2d_overlap_circle(x, y, r) -> count`, then `physics2d_overlap_body(i)`. Joints (anchors in world pixels): `joint2d_revolute(a, b, x, y)`, `joint2d_distance(a, b, ax, ay, bx, by)`, `joint2d_prismatic(a, b, x, y, axis_x, axis_y)`, `joint2d_weld(a, b, x, y)`, `joint2d_mouse(ground, b, x, y)` + `joint2d_target(j, x, y)` (drag with the pointer), `joint2d_motor(j, speed, max_force)` (revolute rad/s, prismatic px/s), `joint2d_limits(j, lo, hi)` (radians / pixels), `joint2d_destroy(j)`. ## 3D (meters, +Y up) Gravity -9.81 (`physics_gravity(v)`). | Create | | | --- | --- | | `body_box(pos, half, kind) -> Body` | half extents | | `body_sphere(pos, r, kind)` | | | `body_capsule(pos, h, r, kind)`, `body_cylinder(pos, h, r, kind)` | upright, total height h | | `body_convex(model, xf, kind)`, `body_convex_mesh(mesh, xf, kind)` | convex hull of a model / mesh placed at xf (props, debris) | | `body_mesh(model, xf)`, `body_mesh_mesh(mesh, xf)` | exact triangles, static only (levels, buildings, rocks) | | `body_heightfield(heights: f32[], samples_x, samples_z, cell, origin)` | static terrain, `heights[z * samples_x + x]`, origin = sample (0, 0) | Density is water-like (1000 kg/m^3), so a 1 m box is 1000 kg; set real masses with `body_mass(b, kg)`. Default friction 0.6, bounce 0. State: `body_xform(b) -> Mat4`, `body_pos`, `body_rot -> float4`, `body_vel`, `body_spin`, `body_set_pos/_set_rot/_set_xform/_set_vel/_set_spin`, `body_impulse(b, i)`, `body_impulse_at(b, i, point)`, `body_force`, `body_torque`, `body_move(b, pos, rot, dt)` (kinematic: doors, platforms, elevators), `body_friction`, `body_bounce`, `body_mass`, `body_damping(b, linear, angular)`, `body_gravity_scale`, `body_ccd(b, true)` (fast small things), `body_sensor(b, true)` (trigger, reported as contacts), `body_tag(b, i32)` / `body_get_tag(b)` (-1 for ragdoll parts), `body_layer(b, l)`, `body_wake(b)`, `body_awake(b)`, `body_destroy(b)`, `body_valid(b)`. Layers 0..15: `LAYER_STATIC` 0 (static bodies), `LAYER_DYNAMIC` 1 (everything else), `LAYER_CHARACTER` 2, `LAYER_RAGDOLL` 3, `LAYER_DEBRIS` 4 (hits only static), 5..15 yours. All pairs collide except static/static, ragdoll/character and debris/non-static; change with `physics_collide(a, b, on)`. Query masks are layer bits (`1 << layer`), 0 = all. Queries: - `raycast(origin, dir, max_dist, mask, ignore) -> RayHit { body, dist, point, normal }` (`dist < 0`: no hit; `ignore`: a body to skip, e.g. `character_body(player)`). `raycast(origin, dir, max_dist)` hits anything. - `spherecast(origin, radius, dir, max_dist, mask, ignore) -> RayHit`. - `physics_overlap_sphere(center, r, mask) -> count` then `physics_overlap_body(i)`. - `physics_contact_count()`, `physics_contact(i) -> Contact { a, b, point, normal, speed, began }` (speed = closing speed: play an impact sound when it is above ~2). Joints (world anchors; `gander::NO_BODY` as b attaches to the world): `joint_hinge(a, b, point, axis, min_deg, max_deg)` (min >= max: free), `joint_fixed(a, b)`, `joint_point(a, b, point)`, `joint_cone(a, b, point, twist_axis, swing_deg, twist_min, twist_max)`, `joint_distance(a, b, pa, pb, min, max)`, `joint_motor(j, rad_per_s, max_torque)` (hinges), `joint_destroy(j)`. ## Character controller `character(feet, height, radius) -> Character`: a capsule that walks. It steps up stairs (0.4 m), sticks to slopes up to 50 degrees, rides moving platforms, pushes dynamic bodies, collides with other characters, and has an inner body on `LAYER_CHARACTER` so raycasts can hit it. - `character_update(c, move, jump, dt)` once per frame: `move` = desired horizontal velocity (m/s, world), `jump` = upward speed when it should jump (only used on the ground, 0 otherwise). Gravity and air control are handled. - `character_pos(c)` (feet), `character_vel`, `character_on_ground`, `character_ground_normal`, `character_set_pos`, `character_set_vel` (knockback), `character_height(c, h) -> bool` (crouch; false when blocked above), `character_body(c)`, `character_destroy(c)`. - `character_limits(c, step, max_slope_deg)`: the tallest step walked up (default 0.4 m) and the steepest slope walked on (default 50 degrees; crater banks 58). ## Ragdolls `ragdoll(anim) -> Ragdoll` lays 12 bodies on a character's skeleton by bone name (Hips, Spine*, Neck, Head, Left/Right Arm, ForeArm, Hand, UpLeg, Leg, Foot; Meshy, Mixamo, Unreal and Blender names all map): pelvis, spine, chest, head, upper and lower arms and legs, cone joints at hips / shoulders / spine / neck, hinges that bend only the natural way at knees and elbows. - Draw the character with `draw_ragdoll(r, xf)` **always** (alive too): it animates normally and records the pose so a ragdoll keeps the motion it had. - `ragdoll_start(r, impulse, hit_point)` on death: goes limp from the current pose, the impulse (N s; a rifle round ~40..80 along the bullet) goes into the body nearest hit_point. - `ragdoll_state(r)`: `RAGDOLL_ALIVE`, `RAGDOLL_FALLING`, `RAGDOLL_SETTLED` (baked into a static pose after it rests, or after 4.2 s; costs nothing afterwards). At most 8 fall at once (`ragdoll_max_active(n)`, 1..32); starting one more settles the oldest. - `ragdoll_limits(r, part, swing, twist)`: joint limits in degrees for one part ("head", "l_upperarm", "r_shin" ...): swing is the cone half angle (for elbows and knees the largest bend), twist the turn about the bone; -1 keeps the default. `ragdoll_damping(r, linear, angular)` (0.1 / 0.6): higher falls limper and settles sooner. Both apply from the next `ragdoll_start`. - On a character whose model is still streaming (`model_async`) the ragdoll waits: its calls do nothing until the model arrives. - `ragdoll_impulse(r, impulse, point)` while falling (explosions), `ragdoll_reset(r)` back to animation (respawn). - `ragdoll_bone_hit(r, point, out_name) -> part` for hit-location damage: writes the bone name ("Head", "LeftLeg") and returns 0 pelvis, 1 spine, 2 chest, 3 head, 4/5 left arm, 6/7 right arm, 8/9 left leg, 10/11 right leg. - `body_ragdoll(b) -> Ragdoll` tells which ragdoll a raycast hit. ## Cloth Capes, flags, banners, curtains and tarps: a sheet simulated in `physics_step` (Jolt soft bodies). It is pushed by bodies, characters and ragdolls but never blocks them, and raycasts and shape casts pass through it, so a curtain never stops a player or a bullet. - `cloth(ClothDesc { .. }, xf) -> Cloth` makes the sheet in its pin frame `xf`: x across, hanging down -y, the front facing +Z. - `width`, `height` in metres. - `pin`: `CLOTH_PIN_TOP` (top edge centred on the frame: capes, curtains), `CLOTH_PIN_LEFT` (left edge, running out along +x: a flag from its pole), `CLOTH_PIN_CORNERS` (the two top corners: banners on hooks), `CLOTH_PIN_NONE` (nothing: it falls and drapes over what is below). - `weight` (kg/m^2: 0.1 silk, 0.25 a flag, 0.5 a heavy cape, 1 canvas), `bend` (0 drapes like silk .. 1 holds like canvas), `damping`, `wind` (how much `cloth_wind` moves it). - `backstop`: capes only (see below). `detail`: grid spacing, leave it 0. - `swing_top`, `swing_bottom`: swing limits for pinned sheets (Unreal's painted max distance): how far in metres each row may move from where it hangs in the pin's frame, from the pinned edge to the hem, linear down the rows. A cape: 0.03 at the shoulders, 0.5 at the hem, so it moves with the stride and the wind but never flies up over the head. -1 (default) off. - `cloth_pin(c, xf)`: move the pinned edge. Every frame **before** `physics_step` for a cape; never for a flag that stays put. A jump of more than 4 m carries the whole sheet (respawn, teleport) instead of stretching it. - `draw_cloth(c, material)` every frame. Both sides are lit with any material; a texture's u runs across from the left edge, v down from the top. - `draw_cloth_lined(c, outer, lining)`: instead of `draw_cloth`, a sheet with a lining: `outer` on the front (+Z as made: away from the wearer for a cape), `lining` on the back. Give both single-sided materials. - `cloth_swing_scale(c, k)`: scale every swing limit at run time (1 as made, 1.5 looser while sprinting or in a gale, 0.5 tighter while kneeling). - `cloth_wind(float3 m/s, gust 0..1)`: wind on every sheet (5 m/s makes a flag stand out, 12 is a gale; gust 0.5 gives ripples and gusts). Wind only pushes a sheet facing it: a flag side-on to the wind hangs. - `cloth_push(c, at, radius, vel)`: shove it around a point (a shot through a flag, a blast, an NPC brushing a curtain). - `cloth_point(c, u, v) -> float3`: a point on the sheet as drawn (hang a lantern on a banner, put a sound on a flag's tip). - `cloth_ignore(c, body)`: something the sheet passes through: a cape's wearer (`character_body(player)`), so it never fights the capsule. - `cloth_capsule(c, bone_a, bone_b, radius) -> i32`: a capsule between two bones of the wearer that the sheet stays out of (thighs, shins, arms, a sword; `bone_b` "" makes a sphere at `bone_a`). Make them once, up to 24. - `cloth_wearer(c, anim, xf)`: every frame next to `cloth_pin`, the wearer's animator and the matrix it is drawn with, so the capsules follow its bones. - `cloth_free(c)`. Costs about 0.7 microseconds per vertex per frame. The default grid is 8 cm (5 cm in Pro builds, at most 32 x 32 vertices, 48 x 48 in Pro): a cape is about 0.1 ms, a 2 m curtain about 0.6 ms on the web. Keep it to a few sheets near the player, and `cloth_free` sheets far away. **Capes.** Characters face -Z, so the cape goes on their back at +Z. Pin it with the same matrix you draw the character with, moved to the shoulders; use only the spine bone's position, so the cape hangs straight down from the character instead of twisting with every step of the animation. `backstop` (about 0.02) keeps the cape from swinging into the body, with no colliders on the body: ```goose var cape: gander::Cloth; fn cape_frame(a: gander::Anim, xf: gander::Mat4) -> gander::Mat4 { let s = gander::position(gander::bone(a, "spine_03")); // "Spine2" on Mixamo rigs gander::mul(xf, gander::translate(gander::v3(0.0, s.y + 0.1, s.z + 0.17))) } // main: after the animator exists cape = gander::cloth(gander::ClothDesc { width: 0.6, height: 1.05, pin: gander::CLOTH_PIN_TOP, weight: 0.5, bend: 0.2, backstop: 0.02 }, cape_frame(anim, hero_xf)); gander::cloth_ignore(cape, gander::character_body(player)); // frame gander::cloth_pin(cape, cape_frame(anim, hero_xf)); // before physics_step gander::physics_step(dt); gander::draw_animated(anim, hero_xf); gander::draw_cloth(cape, cape_mat); ``` **A lined cape with a collar.** Swing limits keep the hem's reach believable without colliders; the lining shows where the cape turns over; a collar is a second short, stiff sheet pinned a little higher (it is drawn over the cape's top edge): ```goose cape = gander::cloth(gander::ClothDesc { width: 0.6, height: 1.05, weight: 0.5, bend: 0.2, backstop: 0.02, swing_top: 0.03, swing_bottom: 0.5 }, cape_frame(anim, hero_xf)); collar = gander::cloth(gander::ClothDesc { width: 0.5, height: 0.14, weight: 0.8, bend: 0.9, swing_top: 0.0, swing_bottom: 0.04 }, gander::mul(cape_frame(anim, hero_xf), gander::translate(gander::v3(0.0, 0.05, 0.02)))); // frame: pin both, then gander::draw_cloth_lined(cape, wool_outer, linen_lining); gander::draw_cloth_lined(collar, fur, fur); ``` **A cape that wraps the body.** `backstop` alone keeps a cape off the back, but a stride or a swing still passes through it. Capsules on the limbs make it drape over them instead: after every physics tick, any part of the sheet inside one is put on its surface (a fast swing cannot tunnel through). Size each a little wider than the limb with its armour; a sword's capsule goes from the hand to the blade's socket. Keep the cape's `backstop` small (0.02) with capsules: a loose one lets it swing forward into the legs and bunch. Keep the pinned edge out of every capsule (the engine warns when one holds it: narrow the cape or the capsule). ```goose // main, after cloth(): legs, arms and the blade gander::cloth_capsule(cape, "thigh_l", "calf_l", 0.10); gander::cloth_capsule(cape, "thigh_r", "calf_r", 0.10); gander::cloth_capsule(cape, "calf_l", "foot_l", 0.07); gander::cloth_capsule(cape, "calf_r", "foot_r", 0.07); gander::cloth_capsule(cape, "upperarm_r", "lowerarm_r", 0.07); gander::cloth_capsule(cape, "hand_r", "blade_tip", 0.04); // a socket (gander glb socket) // frame, before physics_step gander::cloth_pin(cape, cape_frame(anim, hero_xf)); gander::cloth_wearer(cape, anim, hero_xf); ``` **Flag on a pole.** The frame sits at the top of the pole; the flag runs out along +x and turns with the wind: ```goose flag = gander::cloth(gander::ClothDesc { width: 1.8, height: 1.1, pin: gander::CLOTH_PIN_LEFT, weight: 0.2, bend: 0.15 }, gander::place(pole_top, 0.0)); gander::cloth_wind(gander::v3(6.0, 0.0, 1.0), 0.5); ``` **Tarp over crates.** `CLOTH_PIN_NONE` with the frame tipped flat (the sheet's down becomes +z), a metre above what it should cover: ```goose let xf = gander::trs(gander::v3(x - 1.1, top + 1.0, z - 1.1), gander::quat_axis(gander::v3(1.0, 0.0, 0.0), -1.5708), gander::v3(1.0, 1.0, 1.0)); tarp = gander::cloth(gander::ClothDesc { width: 2.2, height: 2.2, pin: gander::CLOTH_PIN_NONE, weight: 0.8 }, xf); ``` ## Recipes FPS player: ```goose var player: gander::Character; var yaw: f32 = 0.0; var pitch: f32 = 0.0; // main(): player = gander::character(gander::v3(0.0, 0.0, 0.0), 1.8, 0.3); export "gander_frame" fn frame(dt: f32) { let look = gander::look_axis(); yaw -= gander::deg(look.x); pitch = clamp(pitch - gander::deg(look.y), -1.4, 1.4); let mv = 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") { 6.5 } else { 4.0 }; let jump: f32 = if gander::action_pressed("jump") { 5.0 } else { 0.0 }; gander::character_update(player, (fwd * mv.y + right * mv.x) * speed, jump, dt); gander::physics_step(dt); let eye = gander::character_pos(player) + gander::v3(0.0, 1.65, 0.0); gander::camera_xform(gander::trs(eye, gander::quat_euler(pitch, yaw, 0.0), gander::v3(1.0, 1.0, 1.0)), 70.0); } ``` Bullets (hitscan) with ragdoll deaths and headshots: ```goose let eye = gander::camera_pos(); let dir = gander::camera_forward(); let hit = gander::raycast(eye, dir, 200.0, gander::MASK_ALL, gander::character_body(player)); // enemies are drawn with draw_ragdoll; find who was hit by distance to the hit point // (or keep a capsule body per enemy on a game layer and use its tag) var bone: u8[>..] = []; let part = gander::ragdoll_bone_hit(enemy_doll, hit.point, bone); if part == 3 { damage = 100; } if enemy_hp <= 0 { gander::ragdoll_start(enemy_doll, dir * 60.0, hit.point); } ``` Platformer (2D): ```goose // main(): hero = gander::body2d_capsule(80.0, 600.0, 56.0, 14.0, gander::DYNAMIC); // gander::body2d_fixed_rotation(hero, true); let v = gander::body2d_vel(hero); var vy = v.y; if gander::action_pressed("jump") && gander::body2d_on_ground(hero) { vy = -420.0; } gander::body2d_set_vel(hero, gander::move_axis().x * 260.0, vy); gander::physics2d_step(dt); let p = gander::body2d_pos(hero); // draw the hero sprite at p ``` Coins / triggers (2D): `coin = gander::body2d_sensor(x, y, 30.0, 30.0)`, then after stepping loop `physics2d_sensor_event(i)` and check `e.began && e.a.id == coin.id`. Cars and trucks (3D): use the vehicle API, never hinge-jointed wheels (no suspension, they hop and flip). Build a dynamic chassis, then one call makes the whole car: suspension, engine with torque curve, automatic gearbox, differentials, anti-roll bars, tyre grip, lowered centre of mass. ```goose car_body = gander::body_box(gander::v3(0.0, 1.5, 0.0), gander::v3(0.9, 0.4, 2.0), gander::DYNAMIC); gander::body_mass(car_body, 1300.0); // wheelbase 2.6, track 1.6, wheels at y -0.3 (chassis-local), radius 0.38, travel 0.3 m, all-wheel drive car = gander::vehicle_car(car_body, 2.6, 1.6, -0.3, 0.38, 0.3, 2); gander::vehicle_tune(car, 700.0, 7500.0, 32.0, 0.8); // torque, redline, steering lock, grip (gravel) gander::vehicle_suspension(car, 1.5, 0.5); // soft rally springs // every frame, before physics_step: let m = gander::move_axis(); gander::vehicle_drive(car, m.y, m.x, if gander::action_down("brake") { 1.0 } else { 0.0 }, if gander::action_down("handbrake") { 1.0 } else { 0.0 }); gander::physics_step(dt); // draw: the body from body_xform(car_body); each wheel (axle along X) from // vehicle_wheel_xform(car, i), e.g. a wheel model authored axle-along-X, or // MESH_CYLINDER times gander::rotate_z(1.5708) (its axis is Y) for i: i32 in 0..4 { gander::draw_model(wheel, gander::vehicle_wheel_xform(car, i)); } // HUD and feel: vehicle_speed (m/s, x 3.6 = km/h), vehicle_rpm (engine // sound pitch), vehicle_gear; vehicle_wheel_slip(car, i) x/y past ~0.2/0.15 // = dust, skid marks and tyre squeal; vehicle_wheel_contact for airtime. ``` Forward is the chassis's -Z, wheels 0..3 are front left, front right, rear left, rear right. Throttle below zero reverses once stopped. Drive on a `body_heightfield` (terrain) or meshes: the wheels cast cylinders, so kerbs, rocks and ramps work. Thrown objects / debris: `body_convex(model, xf, gander::DYNAMIC)`, then `body_impulse`; put small debris on `LAYER_DEBRIS` so it never blocks players. Terrain: build the height grid you render with, then `body_heightfield(heights, nx, nz, cell, origin)` so characters and ragdolls stand on it. # gander terrain Open-world landscapes: heightfield terrain up to 4 km x 4 km at 1 m (8 km at 2 m), splat-painted ground, grass, scattered trees and rocks, water, roads, runtime editing. Runs in WebGPU or WebGL2 on laptops and phones. Everything is `gander::` in Goose. A terrain is centered on the world origin: it covers x, z in `[-size/2, size/2]`, heights are world y, 0 is sea level. Call `gander::draw_terrain(t)` every frame after the camera; it draws the ground, grass, scatter and water in one go. ## Make one ```goose var land: gander::Terrain; fn main() { gander::config("Open world", 1280, 720, 0); land = gander::terrain_generate(gander::TerrainGen { size: 2048.0, height: 260.0, seed: 7 }); gander::camera_clip(0.1, 5000.0); // see to the horizon gander::fog(gander::hex(0xc8d4de), 0.0004, 0.003, 200.0); } export "gander_frame" fn frame(dt: f32) { let p = gander::v3(0.0, 0.0, 0.0); let eye = gander::v3(p.x, gander::terrain_height(land, p.x, p.z) + 1.7, p.z); gander::camera(eye, eye + gander::v3(0.0, 0.0, -1.0), 70.0); gander::sun(gander::v3(-0.4, -0.6, -0.3), gander::hex(0xffe6c4), 3.2, true); gander::draw_terrain(land); } ``` `TerrainGen` fields (all optional): `size` meters per side (2048), `resolution` samples per side (0 = about 1 m, capped at 4097, 2049 on phones), `height` tallest peak (220), `seed`, `roughness` 0 rolling .. 1 jagged, `ridges` 0..1 mountain ridges, `warp` 0..1 organic shapes, `erosion` 0..1 gullies and smooth valleys, `island` (edges sink below the sea), `flat_radius` + `flat_x` / `flat_z` (a flat play area or base). Other sources: | Call | Use | | --- | --- | | `terrain_load(path, size, height_min, height_max)` | 8 or 16-bit PNG heightmap (black = min, white = max), or `.r32` raw float32 meters. `size` 0 = 1 m per pixel | | `terrain_from_heights(heights, samples, size)` | your own `f32` array, `samples * samples`, rows along +z | | `terrain_load_at` / `terrain_from_heights_at` | the same, placed anywhere and rectangular (next section) | Load times in the browser: 2 km procedural ~3 s, 4 km ~4 s (erosion included). ### Placed areas: several terrains side by side A terrain is centred on the origin unless you place it. A level with its own ground per area (a fen, a climb, a crag under a castle, each one an Unreal sublevel landscape in a port) lays each where the level plan puts it, any size and aspect, and frees the ones the player has left: ```goose // corner (sample 0, 0) at x -946, z -316; 946 x 632 m; black -8 m, white 30 m fen = gander::terrain_load_at("assets/terrain/fen.png", -946.0, -316.0, 946.0, 632.0, -8.0, 30.0); climb = gander::terrain_from_heights_at(heights, 505, 222, 2.0, 0.0, -220.0); // samples x, z, cell, corner gander::terrain_physics(fen, true); // every frame: draw the ones that are loaded gander::draw_terrain(fen); gander::draw_terrain(climb); // the player crossed into the abbey band: give the fen back gander::terrain_free(fen); ``` | Call | Use | | --- | --- | | `terrain_load_at(path, x, z, size_x, size_z, height_min, height_max)` | a PNG (or `.r32` whose float count fits size_x:size_z) over a rectangle with its corner at (x, z); size 0 = 1 m per pixel | | `terrain_from_heights_at(heights, samples_x, samples_z, cell, x, z)` | your own heights, rows along +z | | `terrain_place(t, x, z)` | move any terrain's corner to (x, z) (its physics body and scatter follow) | | `terrain_bounds(t) -> float4` | x, y = corner x, z; z, w = size x, size z | | `terrain_free(t)` | free its heights, GPU textures, scatter and physics; the handle is dead (calls on it warn once) | A rectangle is held in a square grid of the longer side; past the shorter side nothing is drawn, scattered, raycast or collided, and its water ends at the rectangle's edge, so two areas can touch or overlap a little without fighting. Memory is the square's (a 946 x 632 m area at 2 m: 513 x 513 samples, 1 MB). Queries (`terrain_height`, `terrain_raycast`) answer for that terrain only: ask the one under the point (`terrain_bounds`), or raycast the physics world, which sees every terrain with physics on. Put each area's terrain in that area's group (`gander::group`, in the API sheet) and `group_free` frees it with the rest of the area. ## Ask it things | Call | Returns | | --- | --- | | `terrain_height(t, x, z)` | ground y (bilinear) | | `terrain_normal(t, x, z)` | ground normal | | `on_terrain(t, x, z)` | `float3` on the ground | | `terrain_raycast(t, origin, dir, max_dist)` | `gander::Hit` (dist < 0: none): bullets, mouse picking, line of sight | | `terrain_layer_at(t, x, z)` | dominant ground layer: footstep sounds, tire grip | | `terrain_size` / `terrain_samples` / `terrain_cell` / `terrain_origin` / `terrain_min_height` / `terrain_max_height` | dimensions | | `terrain_heights(t, out)` | every height into a growable `f32[>..<]` (physics) | | `terrain_patches()` / `terrain_scatter_count()` | last frame's draw counts (probe these in tests) | ## Ground layers Eight splat layers, procedural by default, so a terrain looks finished with no art: | Layer | Default rule | | --- | --- | | `LAYER_GRASS` 0 | everywhere else | | `LAYER_DIRT` 1 | noise patches, mid slopes | | `LAYER_ROCK` 2 | steep slopes (triplanar, no stretching on cliffs) | | `LAYER_SAND` 3 | just above sea level | | `LAYER_SNOW` 4 | above `snow_height` | | `LAYER_DRY_GRASS` 5 | noise patches | | `LAYER_GRAVEL` 6 | paint only (roads, paths) | | `LAYER_MUD` 7 | paint only | ```goose gander::terrain_look(land, gander::TerrainLook { snow_height: 180.0, rock_slope: 0.38, dirt: 0.15, dry: 0.6 }); gander::terrain_layer(land, gander::LAYER_GRASS, "assets/textures/meadow.png", "assets/textures/meadow_normal.png", 4.0, 0.9); gander::terrain_layer_tint(land, gander::LAYER_ROCK, gander::hex(0xb8a890), 0.0); // warmer rock, keep tile size ``` `TerrainLook`: `sea_level`, `sand_height`, `snow_height` (default: none unless the terrain tops 350 m), `rock_slope` (0 flat .. 1 vertical), `dirt`, `dry` (0..1), `tint`. `terrain_layer` takes any tileable image (Forge / gpt-image "seamless tileable texture", Poly Haven CC0), an optional normal map (`""` derives one), meters per repeat, roughness. `terrain_debug(t, 1)` shows lighting on flat grey, `2` normals: for checking the shape. ## Grass, trees, rocks ```goose gander::terrain_grass(land, gander::Grass { .. }); // lush default gander::terrain_grass(land, gander::Grass { density: 0.6, height: 0.4, color: gander::hex(0x6b6a3a), tip: gander::hex(0xb8b070) }); // dry steppe ``` Grass grows on the grass and dry-grass layers only (never on roads, rock or sand), sways in the wind and fades out at `radius`. It costs no memory: every blade is computed on the GPU. Phones get half the density automatically. Scatter places a model or mesh by rules, streamed in 32 m cells around the camera, so a 4 km map never holds millions of instances: ```goose let pine = gander::model("assets/models/pine.glb"); let rock = gander::model("assets/models/rock.glb"); gander::terrain_scatter_model(land, pine, gander::Scatter { density: 1.2, spacing: 4.0, layer: gander::LAYER_GRASS, height_min: 8.0, radius: 400.0 }); gander::terrain_scatter_model(land, rock, gander::Scatter { density: 0.4, scale_min: 0.4, scale_max: 2.0, layer: gander::LAYER_ROCK, slope_max: 0.8, align: 1.0, sink: 0.25 }); ``` `Scatter`: `density` per 100 m^2, `spacing` minimum meters apart, `scale_min` / `scale_max`, `slope_max`, `height_min` / `height_max`, `layer` (only where it dominates; -1 anywhere dry), `align` 0 upright .. 1 follows the slope, `radius` shown distance, `sink` (bury the base), `seed`, `shadows`, `impostor`. Two rules with the same seed and numbers land on the same spots: a trunk mesh and a crown mesh make one tree. `terrain_scatter_mesh(t, mesh, material, s)` does the same for `gander::mesh` geometry. Meshy / Forge trees and rocks: generate them at real scale (or set scale_min / scale_max), keep them low poly (they are drawn hundreds of times), `gander::texture_limit(1024)` before loading them. Far trees are cards. A model rule with `radius` 120 or more draws its instances past `impostor` meters (auto: 30% of the radius, 50 to 120 m) as impostors: one camera-facing card each, baked by the engine from the model at 16 angles and lit like it (sun side, fog), fading in over the real tree. That makes forests to the horizon cheap, so `radius: 800.0` or more is fine for open worlds. `impostor: 0.0` keeps real models all the way (close-range maps), a number sets the distance; slope-aligned rules (`align` 0.3 and up) and mesh rules never use cards. Your own far objects (a skyline, a fleet on the horizon): `gander::draw_impostor(model, xf, fade)`, with fade 0..1 dithering it in where you switch. ## Water ```goose gander::terrain_water(land, gander::Water { level: 0.0 }); gander::terrain_water(land, gander::Water { level: 4.0, shallow: gander::hex(0x6fa58a), deep: gander::hex(0x1d3b33), clarity: 1.5, waves: 0.4 }); // murky lake ``` A water plane at `level` to the horizon, colored by depth from the heightfield, sky reflection, sun glint, waves and shoreline foam. It also moves the sand line to the water. ## Edit it (roads, craters, building pads) ```goose var road: float3[>..<] = []; for i: i32 in 0..20 { let k = i as f32 / 19.0; road.push(gander::v3(k * 800.0 - 400.0, -1000000.0, sin(k * 4.0) * 100.0)); // y -1e6: follow the ground } gander::terrain_road(land, road, 7.0, gander::LAYER_GRAVEL); // flatten 7 m wide, banked shoulders, painted gander::terrain_flatten(land, 120.0, -60.0, 25.0, 34.0, 1.0); // a pad for a base at y 34 gander::terrain_raise(land, 10.0, 10.0, 6.0, -2.5); // an explosion crater gander::terrain_smooth(land, 10.0, 10.0, 8.0, 0.5); gander::terrain_paint(land, 10.0, 10.0, 7.0, gander::LAYER_MUD, 0.8); ``` Edits are cheap enough to do every frame (a terraforming tool): only the touched area is re-uploaded. Scatter and grass follow the new ground. ## Feed physics Simplest: `gander::terrain_physics(land, true)` once. The engine makes the heightfield body (and remakes it after edits); on a streamed world it makes one per 256 m tile near the camera as regions load. Bodies standing on ground that unloads go to sleep in place instead of falling through. Before spawning the player or dropping bodies on a streamed world, wait for `gander::terrain_ground_ready(land, x, z)`. By hand, the physics module takes a heightfield of floats. One-time, after creating the terrain: ```goose var heights: f32[>..<] = []; gander::terrain_heights(land, heights); let n = gander::terrain_samples(land); let ground = gander::body_heightfield(heights, n, n, gander::terrain_cell(land), gander::terrain_origin(land)); ``` After editing the ground, rebuild the heightfield body the same way. For a character controller without full physics, `terrain_height` under the feet is enough. ## Streamed worlds (8 to 16 km) Past 4 km, use `terrain_world`: the same `TerrainGen`, baked once into 1 km regions that load around the camera, with the whole map drawn to the horizon from a light overview. ```goose land = gander::terrain_world("highlands", gander::TerrainGen { size: 12288.0, height: 420.0, seed: 9, ridges: 0.7, erosion: 0.6, flat_radius: 80.0, flat_x: -3000.0, flat_z: 2000.0 }); gander::terrain_physics(land, true); gander::camera_clip(0.1, 6000.0); // the default far plane (1000 m) hides everything past 1 km ``` Then run `gander terrain bake` (it builds natively, generates every `terrain_world` in `main()` and writes `assets/terrain//` + `assets/stream/terrain//`); run it again whenever a `TerrainGen` changes (the game logs a warning when the bake is stale). Until baked, `terrain_world` simply generates the map in memory like `terrain_generate`. * Sizes: up to 8192 m at 1 m samples, 16384 m at 2 m (rounded up to whole kilometres). Each region is about 1.2 MB, fetched when the camera nears it; the overview (~1.5 MB) ships in the game's first download. * Resident at full detail: 3 x 3 regions on the web, 5 x 5 in Pro desktop builds (`terrain_stream_radius(land, r)` to choose). Grass, scatter, decals and water work as on any terrain; regions still loading show the overview, never a hole. * Everything else works on the handle: heights, normals, raycasts, layers, edits (kept for regions that are loaded; a region keeps its edits from then on), look, layers, grass, scatter rules, water. `terrain_regions_resident(land)` is a handy probe. * Spawn only once `terrain_ground_ready(land, x, z)`; a test needs a few seconds of frames for the first regions (`gander test` lets downloads land between steps). ## Recipes **Open-world FPS map.** `terrain_generate` with `size 4096, ridges 0.6, erosion 0.4, flat_radius 60` for the spawn, a few `terrain_road`s between points of interest, `terrain_flatten` pads where you place buildings, grass + pine and rock scatter, fog density 0.0003..0.0006 so the horizon fades, `camera_clip(0.1, 5000.0)`, `shadow_distance(120.0)`. Walk with `y = terrain_height(x, z) + 1.7`; slow the player on `terrain_normal(...).y < 0.75`. **Island.** `island: true`, `terrain_water(Water { .. })`, a scatter of palm models with `height_min: 3.0`, sand comes free along the water. **Racing track.** Build the track as a closed loop of points, `terrain_road(t, track, 14.0, gander::LAYER_GRAVEL)` (or a tarmac texture in layer 6 via `terrain_layer`), grass with `radius 60`, cars read `terrain_layer_at` for grip. **Desert / snow.** `terrain_look` with `dry: 1.0` and a sand-colored grass tint, or a low `snow_height` and grass off, then `terrain_layer` with Poly Haven textures for close-ups. ## Sizes and memory | Map | Samples | CPU heights | GPU | Notes | | --- | --- | --- | --- | --- | | 1 km at 1 m | 1025^2 | 4 MB | ~35 MB | phones fine | | 2 km at 1 m | 2049^2 | 17 MB | ~70 MB | default; phones cap here | | 4 km at 1 m | 4097^2 | 67 MB | ~85 MB | desktop | | 8 km at 2 m | 4097^2 | 67 MB | ~85 MB | set `resolution: 4097` | | 8-16 km streamed | regions | ~80 MB (3 x 3) | ~130 MB web, ~380 MB Pro | `terrain_world` + `gander terrain bake` | GPU memory stays bounded however big the map: a whole-map overview (at most 2049^2) plus a 1025^2 full-resolution window that follows the camera. The 8 ground layers take ~22 MB, painted layers (roads, `terrain_paint`) add up to 34 MB. ## Rules * Call `draw_terrain` after `camera`, every frame. * Do not draw a `MESH_PLANE` ground under a terrain: the terrain is the ground. * Keep heights in meters (a 2 km map with 5000 m peaks looks like spikes); 150..400 m reads as hills to mountains. * Test with probes: `terrain_patches()` (typically 30..120) and `terrain_scatter_count()`. # Worlds: placed objects that stream (gander::world_*) For open worlds with more objects than fit in memory at once: villages, forts, ruins, loot, NPCs spread over kilometres. You write the placements to a file; the engine loads the 128 m cells around the camera, draws the scenery, and tells the game when its gameplay objects come near and go away. Pair it with `terrain_world` for the ground (https://vbgnt.com/docs/gander/terrain.md, "Streamed worlds"). ## The file `assets/world/.txt`, one line each (write it from a script for big worlds): ```text cell 128 # cell size in meters (default 128) load 300 # objects within this of the camera load (default 300; 800 in Pro builds) far 2000 # scenery beyond `load` and up to here shows as impostor cards (default 2000) model house assets/stream/models/house.glb solid model barrel assets/stream/models/barrel.glb model guard assets/stream/models/guard.glb obj house 120.5 ground 33.2 yaw=90 scale=1.2 obj barrel 118 ground 30 tag=loot gold=25 obj guard 125 ground 36 tag=guard route=north hp=80 obj - 100 12 40 tag=spawn team=red ``` * `model NAME PATH [solid]`: models under `assets/stream/` download only when a cell needs them; `solid` gives every object of that model a static mesh collider while its cell is loaded. * `obj MODEL X Y Z [yaw=deg] [scale=s] [tag=word] [key=value ...]`: `Y` can be `ground` (stands on the terrain passed to `world_open`); `-` as the model is an object with no model (spawn points, triggers, zone markers). * An object **without a tag is scenery**: the engine loads, draws, collides and unloads it. An object **with a tag is gameplay**: the game spawns and draws its own state for it. ## The game side ```goose var world: gander::World; var live: i32[>..] = []; // tagged objects in loaded cells fn main() { land = gander::terrain_world("highlands", gen); world = gander::world_open("assets/world/highlands.txt", land); } export "gander_frame" fn frame(dt: f32) { // ... camera ... gander::draw_terrain(land); gander::world_draw(world); // loads cells, draws scenery and far cards var e = gander::world_event(world); while e.kind != gander::WORLD_NONE { if e.kind == gander::WORLD_ENTER { if gander::world_obj_is(world, e.obj, "guard") { spawn_guard(e.obj); } live.push(e.obj); } else { save_and_drop(e.obj); // its cell unloaded: keep what matters } e = gander::world_event(world); } for i: i64 in 0..live.len { // tagged objects are the game's to draw let m = gander::world_obj_model(world, live[i]); if m.id != 0 { gander::draw_model(m, gander::world_obj_xform(world, live[i])); } } } ``` * Object ids (`e.obj`) are stable (the line order in the file): key the game's own state by them, so a looted chest stays looted when its cell comes back. Store that in the save (https://vbgnt.com/docs/gander/saves.md) as a list of ids. * `world_obj_get(world, obj, "gold", out)` reads a value from the line; `world_obj_pos`, `world_obj_yaw`, `world_obj_scale`, `world_obj_xform`, `world_obj_model`, `world_obj_tag`. * `world_source(world, i, pos)` (i 0..7) loads cells around more places than the camera: other players in multiplayer, a convoy far ahead. `world_source_off(world, i)` stops it. * `world_radius(world, load, far)` overrides the file's radii. * Probes: `world_cells_loaded`, `world_drawn` (scenery models), `world_cards` (far cards). ## Rules * Never keep gameplay state only on a tagged object between its LEAVE and the next ENTER: the engine forgets it. Keep it in the game, keyed by the object id. * Do not draw scenery yourself: the engine does, and turns it into cards past `load`. * Big worlds need `camera_clip(0.1, 6000.0)` or the far scenery and terrain are clipped. * Spawn the player once `terrain_ground_ready(land, x, z)` is true. # gander navigation and AI Navmeshes, paths, cover, and crowds of agents that walk them. Everything is `gander::` in Goose. Enemy brains stay your own code (a state machine with `enum` + `match`); the engine answers "how do I get there", "where can I hide" and moves bodies around walls and each other. ## Build a navmesh once ```goose var nav: gander::Nav; fn main() { // ... build the level (walls as MESH_CUBE boxes, models, terrain) nav = gander::nav(gander::NavSettings { radius: 0.4, height: 1.8 }); gander::nav_add_mesh(nav, gander::MESH_CUBE, floor_xf); for w in walls { gander::nav_add_mesh(nav, gander::MESH_CUBE, w); } gander::nav_add_model(nav, house, house_xf); // rest pose, skinned parts skipped gander::nav_add_terrain(nav, land, gander::v3(0.0, 0.0, 0.0), 150.0); // ground within 150 m gander::nav_link(nav, ledge_top, ledge_bottom, false); // a drop only walked down if !gander::nav_build(nav) { gander::fail("no walkable navmesh"); } } ``` Add every walkable and every blocking surface: floors, walls, stairs, props big enough to walk around. `NavSettings`: `radius` (how close to walls), `height` (headroom), `climb` (tallest step), `slope` (degrees), `cell` (voxel size: 0 = radius / 2, good indoors; 0.3 to 0.5 for large outdoor areas, it builds much faster), `tile` (meters per tile, 32). `nav_add_terrain` samples the ground every 4 cells (at least 1 m), too coarse for trenches and ditches a few metres wide: `nav_add_terrain_detail(nav, land, center, radius, step)` takes the step (0 = every height sample; keep the radius to where agents walk). One `Nav` per agent size: a soldier and a tank need two. The log prints tiles, polygons and build time; `nav_debug(nav, color)` draws the polygon edges. ## Paths and queries ```text nav_path(nav, a, b, out: float3[>..]&) -> bool corners of the shortest walk; false when no way (cut off: ends as near as it gets, still true) nav_closest(nav, p) -> float3 snap a point onto the mesh nav_on(nav, p) -> bool is p standing on it nav_raycast(nav, a, b) -> f32 1 = a straight walk gets there; less = a wall or drop first nav_random(nav, center, radius) -> float3 a reachable point (patrols, wandering, spawns) nav_cover(nav, near, radius, threat, out&) -> i32 spots beside walls that hide them from threat, nearest first nav_block(nav, center, size, yaw) -> i32 id closed doors, barricades, a burning car nav_unblock(nav, id) (the touched tiles rebuild over the next frames) ``` `nav_raycast` is line of sight on the floor plan: a cheap "can that guard see the player" before a real `raycast` against physics bodies. `nav_cover` uses the navmesh's walls (tall cover). ## Agents (crowds) The engine walks agents every frame with steering, avoidance and separation; your code decides where they go. ```goose let a = gander::agent_add(nav, spawn, 3.5); // speed m/s gander::agent_move(nav, a, gander::camera_pos()); // re-send when the target moves (a few times a second is plenty) let p = gander::agent_pos(nav, a); // draw the character here let v = gander::agent_vel(nav, a); // face it: yaw = atan2(-v.x, -v.z); pick walk / run by length(v) if gander::agent_arrived(nav, a, 1.0) { ... } // within 1 m of where it was sent ``` `agent_stop`, `agent_teleport`, `agent_speed` (sprint, limp), `agent_remove` (dead: then play a ragdoll from its last position). Up to 128 agents per `Nav`. Agents stand on the navmesh; for jumping or flying enemies, move them yourself and use `nav_path` for the route. ## Recipes **Patrol, chase, search.** State machine per enemy: `Patrol` sends the agent to `nav_random` points (or a list), `Chase` re-sends `agent_move` to the player every 0.25 s while `nav_raycast(nav, eye, player) == 1.0` or a physics `raycast` sees them, `Search` goes to the last seen point then patrols nearby. **Turn to face, then act.** A creature standing still that must face the player (to attack, to breathe fire) turns with its turn clips, not by a rate over its idle: when the angle to the target passes ~40 degrees, play turn_l / turn_r / turn_180 with `anim_root_turn` on and add `anim_root_yaw` to its yaw (the recipe "Turning in place with turn clips" in the API sheet's animation section); attack once it ends. Moving agents keep facing their velocity. **Take cover and shoot.** On first contact: `nav_cover(nav, my_pos, 12.0, player, spots)`, send the agent to `spots[0]`, and when it arrives alternate crouched (behind cover) and standing (fire) every couple of seconds. **Circle and take turns (melee).** Let only one or two enemies attack at once; send the rest to points on a ring around the player (`player + v3(cos(a), 0, sin(a)) * 4`, snapped with `nav_closest`), moving their angles a little each second. **Doors.** `nav_block` the doorway while it is shut and `nav_unblock` when it opens; agents re-route by themselves. # gander UI: menus, HUD, mobile orientation Immediate mode. Call widgets every frame from `frame()`. A button returns `true` on the frame it is pressed; sliders, toggles and choices write through a reference (`vol`, `on`, `choice` are your `var` globals). Nothing to create or destroy. Every widget works with mouse, touch, keyboard and gamepad with no extra code: arrows / WASD / d-pad / left stick move focus to the nearest widget in that direction, `confirm` (Enter, Space, pad A) presses, left / right adjust a focused slider, segmented, select or tabs, and `gander::ui_back()` reports `back` (Esc, Backspace, pad B). A focus ring shows only while a keyboard or pad is in use. On touch screens the whole UI scales up so rows are at least ~48 physical points (`gander::ui_scale()` tells you the factor). ## Ids A label is the widget's id. Text after `##` is part of the id but not shown: `"Delete##save2"`. Two widgets with the same id in one frame both react (the log warns). In loops wrap each item: ```goose for i: i32 in 0..levels.len as i32 { gander::ui_push_id(i); if gander::ui_button("Play") { start(i); } gander::ui_pop_id(); } ``` Modals get their own id scope automatically. ## Layout Widgets stack top to bottom inside the current panel or area, full width, `theme.item_h` tall. | Call | Does | | --- | --- | | `ui_panel(title, anchor, w, h)` ... `ui_panel_end()` | Panel at an anchor, inside the safe area. Sizes `<= 1.0` are fractions of the screen. `h = 0` fits the content (measured each frame). Empty title: no title row. | | `ui_panel_at(title, x, y, w, h)` | Panel at a rect. | | `ui_area(x, y, w, h)` ... `ui_panel_end()` | Same layout, no background (HUD button rows). | | `ui_row(n)` | The next `n` widgets share one row. | | `ui_space(px)`, `ui_separator()` | Gaps. | | `ui_scroll(name, h)` ... `ui_scroll_end()` | Clipped list; wheel, touch drag, and focus keeps the focused item visible. `h = 0`: rest of the panel. | | `ui_modal(title, w, h)` ... `ui_modal_end()` | Dims the screen, takes the focus, blocks everything behind it. | | `ui_anchor(anchor, w, h, margin) -> float2` | Top-left of a rect at an anchor, inside the safe area (for your own drawing). | Anchors: `gander::TOP_LEFT, TOP, TOP_RIGHT, LEFT_CENTER, CENTER, RIGHT_CENTER, BOTTOM_LEFT, BOTTOM, BOTTOM_RIGHT`. ## Widgets | Call | Returns | | --- | --- | | `ui_title(text, align)` | big heading | | `ui_label(text)` / `ui_label(text, align, dim)` | one line | | `ui_text(text)` / `ui_text(text, align, dim)` | word-wrapped paragraph (outside a UI layout, in a canvas or a HUD box of your own: `gander::text_wrap` / `text_wrap_height` in the API sheet's 2D section) | | `ui_button(label)` / `ui_button(label, style)` | `bool` pressed. Styles `BUTTON, BUTTON_PRIMARY, BUTTON_DANGER, BUTTON_GHOST` | | `ui_toggle(label, on: bool&)` | `bool` changed (switch) | | `ui_checkbox(label, on: bool&)` | `bool` changed | | `ui_slider(label, v: f32&, lo, hi, step, decimals)` | `bool` changed (`step 0`: continuous) | | `ui_segmented(label, "Easy,Normal,Hard", i: i32&)` | `bool` changed | | `ui_select(label, "A,B,C", i: i32&)` | `bool` changed. Arrows cycle, the middle opens a list | | `ui_tabs("Audio,Video", i: i32&)` | `bool` changed | | `ui_text_input(label, placeholder, max_len)` | `0`, `TEXT_CHANGED`, `TEXT_SUBMITTED`. Value: `ui_text_get(label, out)`, set with `ui_text_set`. On phones it is a native field (the OS keyboard appears). | | `ui_progress(t, label)` | bar, `t` 0..1 | | `ui_image(tex, h)` | a texture, aspect kept | | `ui_tooltip(text)` | tooltip for the widget just drawn (hover or focus) | | `ui_wants_input()` | `true` when the pointer is on UI or a modal is open: skip gameplay clicks then | | `ui_back()` | `back` pressed this frame | | `ui_focus(label)`, `ui_focused(label)` | move / test focus | | `ui_device()` | `DEVICE_MOUSE, DEVICE_TOUCH, DEVICE_KEYBOARD, DEVICE_GAMEPAD` (last used) | ## Theme ```goose gander::ui_theme(gander::Theme { accent: gander::hex(0xff7a3d), radius: 6.0, font: my_font, click_sound: click, .. }); ``` Fields: `panel, surface, surface_hover, accent, text, text_dim, danger, overlay` (colors), `radius, padding, spacing, item_h, font_size, title_size`, `font` (a `gander::font`, `Font { 0 }` built in), `hover_sound`, `click_sound` (a `gander::sound`, `Sound { 0 }` none). ## Ready-made screens | Call | Returns | | --- | --- | | `main_menu(title, subtitle, "Play,Settings,Credits")` | index pressed or `-1`; left aligned over your world (draw an attract scene behind) | | `pause_menu("Resume,Settings,Quit")` | index or `-1`; dims the game | | `game_over(title, subtitle, "Retry,Menu")` | index or `-1` | | `ui_settings(settings: Settings&)` | `true` on the frame it closes (Done / back). Tabs Audio / Controls / Graphics, applies live, saves | | `ui_save_slots(mode, count)` | slot picked this frame, `SLOTS_OPEN` (-1) while open, `SLOTS_BACK` (-2) closed. `SLOTS_SAVE` / `SLOTS_LOAD`: slot cards with label and "12 min ago", Delete, confirm before overwriting. Picking only picks: save and load with `save_*` (https://vbgnt.com/docs/gander/saves.md) | | `ui_confirm(title, message, yes, no)` | `-1` open, `1` yes, `0` no / back | | `ui_loading(title, progress, tip)` | full-screen loading screen | | `ui_dialogue(speaker, text, portrait, "Choice A,Choice B", chars_per_sec)` | `-1` while showing; no choices: `0` when the player continues; with choices: the index. A press while typing reveals the rest. `dialogue(speaker, text)` is the short form | | `ui_dialogue_progress()` | 0..1 of the line revealed (drive a face's talking with it) | | `ui_toast(text, seconds)` | notification at the top | Menus release the mouse pointer lock; call `gander::pointer_lock(true)` again when gameplay resumes (it re-locks on the next click). `Settings { master, music, sfx, voice, sensitivity, quality, invert_y, show_fps }`: `settings_load(s)`, `settings_apply(s)` (bus volumes, look sensitivity and invert Y inside `look_axis()`, quality 0 low / 1 medium / 2 high), `settings_save(s)`. ### The whole loop in one call: `Flow` ```goose var flow = gander::Flow { .. }; fn main() { gander::config("Iron Harbor", 1280, 720, 0); gander::flow_init(flow); // loads + applies saved settings } export "gander_frame" fn frame(dt: f32) { let playing = gander::flow_update(flow, "IRON HARBOR", "Hold the docks until dawn"); if flow.started { reset_round(); } // Play or Play again chosen this frame if playing { simulate(dt); } // pause menu is "pause" (Esc / P / Start) draw_world(); // always draw: menus sit on top if flow.screen == gander::SCREEN_PLAYING { draw_hud(); } if dead() { gander::flow_game_over(flow, "YOU FELL", "The harbor is lost"); } } ``` Screens: `SCREEN_MAIN, SCREEN_PLAYING, SCREEN_PAUSED, SCREEN_SETTINGS, SCREEN_OVER, SCREEN_SAVE, SCREEN_LOAD`. Save games: `gander::Flow { saves: 3, .. }` adds Continue / New game / Load game to the main menu (once a save exists) and Save game / Load game to the pause menu. On the frame the player picks a slot, `flow.save_to` (write it) or `flow.load_from` (read it; `flow.started` is true too, so reset first) is that slot, -1 otherwise. The whole pattern is in https://vbgnt.com/docs/gander/saves.md. Quit: where `gander::can_quit()` is true (desktop and Android exports; never the web or iOS) the main, pause and game over menus end with Quit, so web builds look as before. Choosing it sets `flow.quitting` for that frame, the game's last (`gander::quit()` ends it after the frame; from the pause menu with saves, `flow.save_to` is 0 too, so the usual `write_save(flow.save_to)` writes the autosave). Your own menus: add Quit only when `can_quit()`. `flow_game_over` keeps up to 48 bytes of title and 96 of subtitle: keep them short. ## HUD Drawn in screen space; positions are logical px. Keep them inside `gander::safe_area()` (x left, y top, z right, w bottom) and, with touch controls, above `gander::touch_insets()` (x left width, y left height, z right width, w right height). | Call | Draws | | --- | --- | | `hud_bar(name, x, y, w, h, value, max, style, color)` | `BAR_SMOOTH` (with a trailing damage ghost), `BAR_SEGMENTED`, `BAR_THIN`; pulses red under 25% | | `hud_radial(x, y, r, thick, value, max, color)` | ring meter (cooldown, reload, stamina) | | `hud_ammo(x, y, mag, mag_size, reserve, align)` | big count, reserve, round ticks; red when low | | `hud_crosshair(style, spread, color)` | `CROSS_DOT, CROSS_CROSS, CROSS_CIRCLE, CROSS_CROSS_DOT, CROSS_CHEVRON`; `spread` px for movement / recoil | | `hud_hitmarker(kill)` | hit (white) or kill (red) marker, fades by itself | | `hud_damage(from: float3)` | red arc pointing at where the hit came from, fades by itself | | `hud_marker(pos, label, color) -> bool` | objective diamond + distance; sticks to the screen edge with an arrow when off screen | | `hud_compass(yaw, y, w)` then `hud_compass_marker(pos, color)` | heading strip (yaw 0 = facing -Z = North) | | `hud_minimap(x, y, size, player, yaw, radius)` ... `hud_minimap_blip(wx, wz, color, r)` ... `hud_minimap_end()` | rotating radar. `hud_minimap_begin(..., map_texture, wx0, wz0, wx1, wz1)` puts a map image under it | | `hud_prompt(action, text, x, y)` | "Press E Open" with the right glyph for keyboard (bound key), gamepad (A/B/X/Y in color) or touch (Tap) | | `hud_float_text(pos, text, color, size)` | floating damage number at a world point | | `hud_counter(name, x, y, value, label, size, align)` | number that pops when it changes | | `hud_timer(x, y, seconds, size, align, tenths)` | m:ss, red under 10 s | Extra 2D primitives: `rounded_rect`, `rounded_rect_outline`, `shadow` (soft), `gradient_rect`, `arc` (radians, 0 up, clockwise), `triangle`, `clip` ... `clip_end`. ## Mobile: portrait, landscape, safe areas `config()` flags, combined with the others: | Flag | Logical space | Phone held the other way | | --- | --- | --- | | none / `LANDSCAPE` | height = the config short side (720), width follows the screen | `LANDSCAPE`: "rotate your device" prompt, game letterboxed | | `PORTRAIT` | width = the config short side, height follows the screen | rotate prompt, letterboxed (desktop windows letterbox too) | | `ANY_ORIENTATION` | short side fixed, adapts | `gander::is_portrait()` tells you which layout to draw | `gander::set_orientation(flags)` switches at runtime (portrait menu, landscape race). Menus lay out for the orientation (portrait: title high, buttons low where the thumb is). Touch controls size from the short side and stay inside the safe area; in portrait the stick zone is the lower half. ## Recipes ### FPS HUD ```goose fn draw_hud() { let w = gander::width(); let h = gander::height(); let sa = gander::safe_area(); let ins = gander::touch_insets(); let lb = max(sa.w, ins.y); // bottom-left clearance let rb = max(sa.w, ins.w); // bottom-right clearance gander::hud_crosshair(gander::CROSS_CROSS_DOT, spread, gander::WHITE); gander::hud_bar("hp", 32.0 + sa.x, h - 60.0 - lb, 300.0, 16.0, hp, 100.0, gander::BAR_SMOOTH, gander::hex(0x5ad16a)); gander::hud_ammo(w - 32.0 - sa.z, h - 110.0 - rb, mag, 30, reserve, gander::ALIGN_RIGHT); gander::hud_compass(yaw, 14.0, 520.0); gander::hud_compass_marker(objective, gander::YELLOW); let shown = gander::hud_marker(objective, "Cargo", gander::YELLOW); gander::hud_minimap(w - 212.0 - sa.z, 24.0 + sa.y, 188.0, gander::camera_pos(), yaw, 30.0); gander::hud_minimap_blip(objective.x, objective.z, gander::YELLOW, 5.0); gander::hud_minimap_end(); if near_crate { gander::hud_prompt("interact", "Open crate", w * 0.5, h * 0.66); } } ``` When a bullet hits: `gander::hud_hitmarker(killed)` and `gander::hud_float_text(hit_point, "25", gander::YELLOW, 28.0)`. When the player is hit: `gander::hud_damage(attacker_pos)`. ### Portrait puzzle game ```goose fn main() { gander::config("Gem Drift", 720, 1280, gander::PORTRAIT); } export "gander_frame" fn frame(dt: f32) { let w = gander::width(); // 720 let h = gander::height(); // taller on long phones: lay out from it let sa = gander::safe_area(); gander::hud_counter("score", 28.0 + sa.x, 24.0 + sa.y, score, "SCORE", 46.0, gander::ALIGN_LEFT); // board centered in the middle, buttons in the thumb zone: gander::ui_area(24.0 + sa.x, h - 120.0 - sa.w, w - 48.0 - sa.x - sa.z, 100.0); gander::ui_row(2); if gander::ui_button("Hint") { gander::ui_toast("Try the blue gems", 2.0); } if gander::ui_button("Pause", gander::BUTTON_PRIMARY) { paused = true; } gander::ui_panel_end(); } ``` Board taps: `gander::touch_at(0)` (z = 1 on the first frame of a touch); skip them when `gander::ui_wants_input()`. ### Dialogue scene ```goose var line: i32 = 0; var voice: u32 = 0; export "gander_frame" fn frame(dt: f32) { if line == 0 { if gander::ui_dialogue("Mara", "The convoy left at dawn.", mara_portrait, "", 45.0) == 0 { line = 1; } } else if line == 1 { let k = gander::ui_dialogue("Mara", "Your call, captain.", mara_portrait, "Take the river road,Wait for nightfall", 45.0); if k >= 0 { route = k; line = 2; } } } ``` Start the line's voice clip on the frame the line changes (keep the voice handle; `gander::voice_playing(voice)` tells you when it ends). ### Settings from a pause menu without Flow ```goose var settings = gander::Settings { .. }; var screen: i32 = 0; // 0 game, 1 paused, 2 settings fn main() { let ok = gander::settings_load(settings); gander::settings_apply(settings); } export "gander_frame" fn frame(dt: f32) { if screen == 0 && gander::action_pressed("pause") { screen = 1; } if screen == 1 { let k = gander::pause_menu("Resume,Settings"); if k == 0 || gander::ui_back() { screen = 0; gander::pointer_lock(true); } if k == 1 { screen = 2; } } else if screen == 2 { if gander::ui_settings(settings) { screen = 1; } } } ``` ## Gotchas * Float literals are `f64` in Goose: `i * 0.7` is `f64`; write `(i as f32) * 0.7`, or keep values `f32` typed. * Menus and HUD are drawn every frame you call them; stop calling to hide. * `ui_panel_end()` closes `ui_panel`, `ui_panel_at` and `ui_area`; `ui_scroll` needs `ui_scroll_end()`, `ui_modal` needs `ui_modal_end()`. * No emoji in labels (the font has none); use words or draw icons with the primitives. # gander saves: slots, autosave, Continue, and writing game state Save games for anything a player comes back to: levels, unlocks, a run, a story. A save is a record of named fields in a slot. Slot 0 is the autosave, 1..31 the player's slots. The engine keeps the slots in the browser's storage on the web and in the per-user data folder natively; nothing goes to a server, so a save stays on the device and browser it was made on. Nothing is saved for you. The engine does not snapshot the scene, physics, animation or audio: the game writes the state it needs to rebuild them. ## Writing and reading a save ```goose fn write_save(slot: i32) { gander::save_begin(); gander::save_put("v", 1); // your save format version gander::save_put("level", level); // i32, i64, f32, f64, bool gander::save_put("pos", hero_pos); // float2, float3, float4 gander::save_put("yaw", hero_yaw); gander::save_put("hp", hp); gander::save_put("name", hero_name); // text let ok = gander::save_end(slot, str("Level ", level, ", ", zone_name)); // label on the slot screen } fn read_save(slot: i32) -> bool { if !gander::save_open(slot) { return false; } // empty slot level = gander::save_get("level", 1); // the default is used when the field is missing hero_pos = gander::save_get("pos", level_start(level)); hero_yaw = gander::save_get("yaw", 0.0 as f32); hp = gander::save_get("hp", hp); // current value as the default keeps the type var nm: u8[>..] = []; if gander::save_get("name", nm) { hero_name = nm; } true } ``` | Call | Does | | --- | --- | | `save_begin()` | starts a record | | `save_put(key, value)` | one field; keys are text without `=` or line breaks, not starting with `@` | | `save_end(slot, label) -> bool` | writes it; `false` when the storage refused it (full, private window) | | `save_open(slot) -> bool` | opens a slot for reading; `false` when empty | | `save_get(key, default)` | the field as the default's type (i32, f32, f64, bool, float2/3/4), or the default when missing or unreadable. An integer literal default reads an i32; an i64 field: `save_get_i(key, def)` | | `save_get(key, out: u8[>..]&) -> bool` | text, appended to `out` | | `save_has(key) -> bool` | the open save has the field | | `save_exists(slot)`, `save_delete(slot)`, `save_latest() -> i32` (newest slot, -1 none) | | | `save_label(slot, out&)`, `save_ago(slot, out&)` ("12 min ago"), `save_time(slot) -> i64` (unix seconds) | for your own slot UI | | `unix_time() -> i64` | wall clock (daily rewards, timers across sessions) | Defaults are typed: float literals are f64, so `save_get("hp", 0.0)` returns an f64. Pass the current value (`save_get("hp", hp)`) or cast (`0.0 as f32`). ## What to save Save what you cannot rebuild, and rebuild the rest from it on load: * **Progress**: level or chapter, checkpoint id, unlocked items, collected pickups, quest steps, money, score, time played. * **The player**: position and yaw (at a checkpoint is fine), health, ammo, inventory, equipped item. * **The world**, only what changed: opened doors, killed bosses, picked up items. Loading a level and then applying the changes beats saving every entity. Do not save handles (`Model`, `Texture`, `Sound`, `Body`...: they are only valid in this run), physics velocities, animation times, particles or camera smoothing. On load, reset the round first (as for a new game), then apply the save, then put physics bodies where the save says (`body_set_pos`). Lists: save the count, then one field per item with the index in the key. ```goose gander::save_put("items", inventory.len as i32); for it, i in inventory { gander::save_put(str("item", i, ".kind"), it.kind); gander::save_put(str("item", i, ".count"), it.count); } // reading let n = gander::save_get("items", 0); for i: i32 in 0..n { inventory.push(Item { kind: gander::save_get(str("item", i, ".kind"), 0), count: gander::save_get(str("item", i, ".count"), 1) }); } ``` Booleans per thing (doors, collectibles) can share one text field: a string of `0` and `1`, or a comma list of ids. **Versions**: the game will change after players have saves. Never reuse a key for a different meaning; add new keys (old saves read their default). Save `"v"` and branch on it when a change needs converting. ## The slot screen `ui_save_slots(mode, count) -> i32` is the whole screen: one card per slot with its label and "12 min ago", Delete on each, a confirm before overwriting or deleting, Back. It works with mouse, touch, keys and pads. * `gander::SLOTS_SAVE` lists slots 1..count (save into one), `gander::SLOTS_LOAD` the autosave too, when there is one. * Returns the slot picked this frame, `SLOTS_OPEN` (-1) while open, `SLOTS_BACK` (-2) when closed. It only picks: you save or load. ## With Flow (the usual way) `Flow { saves: 3, .. }` adds the save menus to the ready-made game flow: the main menu gets Continue (the newest save, autosave included), New game, Load game and Settings once a save exists; the pause menu gets Save game and Load game. Flow sets `save_to` or `load_from` to the slot on the frame the player picks it (-1 otherwise), and toasts "Game saved" the frame after, or "Could not save the game" when nothing was written. On desktop and Android the pause menu also has Quit: on that frame `save_to` is 0 (the autosave) and `flow.quitting` is true, and the game ends after the frame. ```goose var flow = gander::Flow { saves: 3, .. }; fn main() { gander::config("Iron Harbor", 1280, 720, 0); gander::flow_init(flow); } export "gander_frame" fn frame(dt: f32) { let playing = gander::flow_update(flow, "IRON HARBOR", "Hold the docks until dawn"); if flow.started { reset_round(); } // New game, Continue, Load, Play again if flow.load_from >= 0 { let ok = read_save(flow.load_from); } if flow.save_to >= 0 { write_save(flow.save_to); } if playing { simulate(dt); } draw_world(); } ``` Without Flow, open the screen from your own menus: ```goose if screen == SCREEN_SAVE { let k = gander::ui_save_slots(gander::SLOTS_SAVE, 3); if k >= 0 { write_save(k); gander::ui_toast("Game saved", 2.0); screen = SCREEN_PAUSED; } if k == gander::SLOTS_BACK { screen = SCREEN_PAUSED; } } ``` ## Autosave Write slot 0 at natural moments: a checkpoint, a level cleared, a shop closed, every few minutes of play. Never every frame: each save is a synchronous storage write. ```goose fn checkpoint() { write_save(0); gander::ui_toast("Checkpoint", 1.5); } ``` Short arcade games with no progress beyond a high score do not need slots: use the high score boards (https://vbgnt.com/docs/gander/api.md, Analytics and high scores), and `save(key, value)` / `load(key, out)` for a single setting or best time. ## Testing `gander test` starts with empty storage every run (`gander test --native` keeps its saves in the per-user folder between runs). Save and load in the same run: drive the menus with `--input`, or call `write_save(1)` and `read_save(1)` at fixed frames, and probe the values that should come back. # gander online multiplayer Rooms, codes, quick play and relays come from vbgnt's room service (the same one Godot games use). The model is **host authoritative**: the host (peer 1) runs the game; guests send their input and draw what the host sends back. The host is a player (peer to peer, free) or, for games that opt in, this same game running headless on a vbgnt cloud host. Everything polls: no callbacks, call `net_state()` and drain `net_poll()` every frame. ## API ```text net_available() -> bool online play possible in this build (true on vbgnt; false locally / in tests) net_set_name(name) sent with joins; the host reads it with net_peer_name net_host(max_players, public, mode) open a room you host (2..8); public lists it for quick play net_join(code) join by room code ("K7PQ") net_quick_play(mode, max_players) a seat in an open public room of this mode, or a new one (on a cloud host when the game has one, else you host) net_server_room(max_players, public, mode) a cloud-hosted room you join as a player (NET_WAITING while it boots) net_warm_up(mode, max_players) the online lobby opened: start booting a cloud host early (fire and forget) net_leave() host: closes the room for everyone net_set_open(open) host: stop / resume accepting players (match started) net_state() -> NET_IDLE | NET_CONNECTING | NET_WAITING | NET_HOSTING | NET_JOINING | NET_CONNECTED | NET_FAILED net_error(out&) why it failed: room_not_found, room_full, room_closed, version_mismatch, connect_timeout, signaling_timeout, server_timeout, not_configured, ... net_room_code(out&) net_invite_link(out&) net_invite_code(out&) (?room=CODE on the play URL) net_is_host() net_is_server() net_is_server_room() net_my_id() net_max_players() net_peer_count() net_peer_id(i) net_peer_name(id, out&) net_send(to, channel, bytes) -> bool to: a peer id, or NET_ALL (host: every guest; guest: the host) channel: NET_RELIABLE (ordered, never lost) or NET_UNRELIABLE (newest wins) net_poll() -> NET_NONE | NET_MESSAGE | NET_PEER_JOINED | NET_PEER_LEFT | NET_HOST_LEFT net_msg_from() net_msg_channel() net_msg_len() net_msg_data(out&) the event net_poll returned net_in_room() -> bool hosting, or connected to a host net_server_shutdown(code) cloud host only: close the room and exit (match over) ``` Messages: build with `net_put_u8 / u16 / i32 / f32 / float2 / float3 / quat / str`, read back in the same order with a `NetReader` (`net_read_*`, then `net_read_ok(data, r)` to check nothing ran past the end). Rates: `NetTimer` + `net_every(timer, hz, dt)`. Smooth remote movement: one `NetTrack` per remote entity, `net_track_push(tr, pos, rot)` when a snapshot arrives, `net_track_pos(tr)` / `net_track_rot(tr)` when drawing (100 ms behind, always between two real states). `net_track_index(tracks, id)` finds or adds a track. ## The rules - **The host decides.** Guests send input (what the player pressed), never positions, hits or scores. The host simulates and sends results. Never trust a guest's message: clamp every value you read (`clamp(mv.x, -1.0, 1.0)`). - **Unreliable for state, reliable for events.** Input every frame (30 Hz is plenty) and snapshots (15..20 Hz) go on `NET_UNRELIABLE`: a lost one is replaced by the next. Joins, chat, pickups, deaths, turns, round results go on `NET_RELIABLE`. - **Small messages.** A snapshot is a type byte, a count, then per entity an id and what changed. Under 1 KB per snapshot; never send textures, models or long strings each frame. - **Peer ids** are below 2^24 (exact as f32). The host is always 1. Guests only talk to the host: to reach other guests, the host forwards. - **Rooms end with the host** (`NET_HOST_LEFT` on guests): show a message and go back to the menu. Cloud-hosted rooms keep going until empty. - **Joining late**: when the host sees `NET_PEER_JOINED`, send that peer a reliable "welcome" with the full current state (and its spawn). - **Local builds and `gander test`** have no room service (`net_available()` is false): keep single-player / same-screen play working without it, and show "Online play is available when published" instead of failing. - Show the room code big, with a Copy invite button (`net_invite_link`), and a spinner with the code while `NET_WAITING` (a cloud host takes up to a minute to boot the first time; `net_warm_up` when the lobby opens shortens it). ## Cloud hosts Opt in with `gander.json`: ```json { "net": { "server": true } } ``` Then vbgnt can run the game itself as the room's host on demand (quick play and `net_server_room` use it; up to 16 players). The SAME code runs there, compiled natively and headless: `net_is_server()` is true, there is no local player, no window and no sound, and `main()` should skip menus. Branch on it: ```goose export "gander_frame" fn frame(dt: f32) { if gander::net_is_server() { // no menu, no local player: simulate the room, send snapshots } } ``` The cloud host exits by itself once the room has been empty for 45 s (90 s if nobody ever came), or on `net_server_shutdown(0)`. ## Recipe: co-op arena (snapshots + input forwarding) ```goose let MSG_INPUT = 1; let MSG_SNAP = 2; struct Player { id: i32, pos: float2, input: float2 } var players: Player[>..] = []; var tracks: gander::NetTrack[>..] = []; var send_timer = gander::NetTimer { .. }; var snap_timer = gander::NetTimer { .. }; fn player_index(id: i32) -> i64 { for i: i64 in 0..players.len { if players[i].id == id { return i; } } players.push(Player { id: id, pos: float2 { 640.0, 360.0 }, input: float2 { 0.0, 0.0 } }); players.len - 1 } fn host_frame(dt: f32) { if !gander::net_is_server() { let me = player_index(1); players[me].input = gander::move_axis(); } while true { let ev = gander::net_poll(); if ev == gander::NET_NONE { break; } if ev == gander::NET_PEER_JOINED { let i = player_index(gander::net_msg_from()); } if ev == gander::NET_MESSAGE { var data: u8[>..] = []; gander::net_msg_data(data); var r = gander::NetReader { .. }; if gander::net_read_u8(data, r) == MSG_INPUT { let mv = gander::net_read_float2(data, r); let i = player_index(gander::net_msg_from()); players[i].input = float2 { clamp(mv.x, -1.0, 1.0), clamp(mv.y, -1.0, 1.0) }; } } } for &p in players { p.pos = p.pos + float2 { p.input.x, -p.input.y } * (260.0 * dt); } if gander::net_every(snap_timer, 20.0, dt) { var m: u8[>..] = []; gander::net_put_u8(m, MSG_SNAP); gander::net_put_u8(m, players.len as i32); for p in players { gander::net_put_i32(m, p.id); gander::net_put_float2(m, p.pos); } let ok = gander::net_send(gander::NET_ALL, gander::NET_UNRELIABLE, m); } for p in players { gander::circle(p.pos.x, p.pos.y, 26.0, gander::WHITE); } } fn guest_frame(dt: f32) { while true { let ev = gander::net_poll(); if ev == gander::NET_NONE { break; } if ev == gander::NET_MESSAGE { var data: u8[>..] = []; gander::net_msg_data(data); var r = gander::NetReader { .. }; if gander::net_read_u8(data, r) == MSG_SNAP { let n = gander::net_read_u8(data, r); for k: i32 in 0..n { let id = gander::net_read_i32(data, r); let pos = gander::net_read_float2(data, r); let t = gander::net_track_index(tracks, id); gander::net_track_push(tracks[t], float3 { pos.x, pos.y, 0.0 }, gander::quat_identity()); } } } } if gander::net_every(send_timer, 30.0, dt) { var m: u8[>..] = []; gander::net_put_u8(m, MSG_INPUT); gander::net_put_float2(m, gander::move_axis()); let ok = gander::net_send(gander::NET_HOST, gander::NET_UNRELIABLE, m); } for tr in tracks { let p = gander::net_track_pos(tr); gander::circle(p.x, p.y, 26.0, gander::WHITE); } } export "gander_frame" fn frame(dt: f32) { let st = gander::net_state(); if st == gander::NET_HOSTING { host_frame(dt); } else if st == gander::NET_CONNECTED { guest_frame(dt); } } ``` Start it from a menu: `gander::net_quick_play("arena", 4)` for "Play online", `gander::net_host(4, false, "")` + show the code for "Invite friends", and on boot `net_invite_code(code)` + `net_join(code)` when the page was opened from an invite link. ## Recipe: turn-based board game Everything on `NET_RELIABLE`: the guest sends `MOVE square`; the host checks it is that player's turn and the move is legal, applies it, then sends `STATE` (the whole board, it is small) to `NET_ALL`. Lock the room with `net_set_open(false)` when the game starts. No snapshots, no interpolation. ## Recipe: party game (4 players, input only) Guests send `INPUT buttons` every frame on `NET_UNRELIABLE` (a bit field of `action_down`s) plus `PRESS action` on `NET_RELIABLE` for one-shot presses that must not be lost (jump, buzz in). The host runs the round and sends `EVENT` messages (round start, score, winner) on `NET_RELIABLE` and positions on `NET_UNRELIABLE`. ## Native builds Exported desktop and mobile builds (Windows, macOS, Linux / Steam Deck, Android, iOS) play online too, in the same rooms as the web build: a native player can host a room web players join, or join a web player's room, with the same code and nothing to change in the game. The export writes the room service address and the build id next to the game (gander-config.json; the build id is the hash of game.c, the same one the web build reports, so the room service keeps native and web players of one build together). `net_invite_code` reads `--room CODE` from the command line natively, the web build's `?room=`. Test it with `gander test --native --realtime` (frames paced in real time so connections can happen). ## Not yet Voice chat (Godot games have it through vbgnt_net). # Talking characters: assets for gander faces `gander::face()` needs a model with face blendshapes. Meshy and Forge characters have none. Two tools give you one (both CC0-safe, both output plain GLB that gander loads; run them in the game folder): ```sh F=/opt/gander/tools/face python3 $F/make_human.py --sex female --age 30 --race asian --hair ponytail01 --out assets/models/hero.glb python3 $F/add_face.py assets/models/soldier.glb assets/models/soldier_talking.glb python3 $F/bake_lipsync.py assets/voice/*.mp3 --text "the exact line" # one line at a time with --text ``` ## make_human.py: a realistic human that talks MakeHuman body (CC0) with a Mixamo-named skeleton (`mixamorig:Hips`, `mixamorig:Head` ...: gander animation, IK, `look_at` and ragdolls find the bones), skin, eyes, eyebrows, eyelashes, teeth, tongue, hair, clothes, and 67 blendshapes on the head (52 ARKit + 15 visemes). Feet at y = 0, facing +Z, meters. About 4 s and 19 MB. | Option | Values | | --- | --- | | `--sex` / `--gender` | `female` `male` / 0..1 blend | | `--age` | years, 1..90 | | `--muscle --weight --height --proportions` | 0..1, 0.5 average | | `--race` | `caucasian` `african` `asian`, or a mix `african=0.5,caucasian=0.5` | | `--skin` | `auto` (matches age / sex / race) or a skin name | | `--eyes` | `brownlight` (default) `brown` `blue` `bluegreen` `green` `grey` `ice` `lightblue` `deepblue` | | `--hair` / `--hair-color` | `auto` `none` `short01..04` `bob01 bob02` `ponytail01` `long01` `braid01` `afro01` / hex `3b2a1e` | | `--eyebrows --eyelashes` | `eyebrow001..012`, `eyelashes01..04`, `none` | | `--clothes` | `auto`, `none`, or comma list: `male_casualsuit01..03 male_worksuit01 female_casualsuit01..02 female_sportsuit01 shoes01..03` | | `--texture-size` | skin texture size, default 2048 (1024 halves the file) | `--style toon` makes an anime character instead: large eyes and a small nose and chin on the same basemesh (the 67 face shapes still fit), one flat skin colour (`--skin-color ffe2d0`), painted anime irises (`--eye-color 8a5cd6`), clothes repainted in flat tones, and cel materials with ink lines (`--outline 0.0022`) that gander draws as `material_toon` + `material_outline` on load. `--targets eyes/eye-scale-incr=0.5,chin/chin-triangle=0.4` adds any MakeHuman detail target (eyes, head, chin, nose, mouth, cheek, neck ...; a name without `l-` / `r-` shapes both sides). Pieces modelled elsewhere go on with `--head-mesh head.glb --head-local head.json` (an anime head with its own face shapes instead of the basemesh's head and neck; `--head-scale 1.14`), `--hair-mesh hair.glb --hair-chains chains.json` (hair; its swinging pieces get `hair__` bones for `gander::spring_bones(anim, "hair_", 3.0, 0.25, 0.25)`), and `--outfit-texture garment=painted.png`. `--head-info head.json` writes the head's measurements for the tool that models the hair. `--list` prints everything installed. The model has no animations: retarget clips onto it in Blender (Mixamo-style names), or pose it with gander's `bone_rotation` / `ik` / `look_at`, and stand it idle with subtle `look_at` and the face's blinks and gaze. ## add_face.py: give a generated character a face Input: a rigged GLB (Meshy rigging, Forge rig) with a head bone (`Head`, `mixamorig:Head`, `--head-joint NAME`). It finds the face from the texture (skin tones), fits a CC0 template head onto it, and transfers the 52 ARKit + 15 viseme shapes onto the character's own head vertices. Mesh, texture, skeleton and animations stay as they were; the head moves to its own mesh node with the blendshapes. Helmets, hair and hoods do not deform. 20 to 50 s. Limits, by design: generated characters have closed mouths and painted eyes, so the mouth talks with the lips and jaw but does not open a cavity (`--jaw 0.7` default; lower for less stretch), and a blink narrows the painted eye. Expressions (smile, brows, surprise, anger) read clearly. For close-up dialogue scenes prefer `make_human.py` characters. ## vrm_import.py: bring a VRM avatar `python3 $F/vrm_import.py avatar.vrm assets/models/avatar.glb`: a VRoid / VRM 1.0 or 0.x avatar with its expression shapes renamed for `gander::face` (A I U E O, Blink, Joy, Angry, Sorrow, Fun, Surprised, eyeLook*) and its humanoid bones renamed to Mixamo names. Its cel shading and ink lines load as they are. Spring bones and constraints are not converted. It prints the avatar's licence terms: check them before shipping someone else's avatar. ## bake_lipsync.py: the best sync for voice lines Writes `.visemes.json` (Rhubarb Lip Sync format) next to each audio file; gander uses it automatically instead of analyzing the audio live. With Rhubarb installed and `--text` given, the sync is phonetic; otherwise it bakes gander's own analyzer (still smoother than live). WAV directly, MP3 / OGG via ffmpeg. Bake every voice line you generate (the TTS text is the `--text`). A baked line also starts instantly: an unbaked one is decoded in full the first time it plays (a frame stall on a long line), so there is no need to pre-play lines at load. ## In the game ```goose import gander from "./.gander/gander"; var hero: gander::Anim; var hero_face: gander::Face; var line1: gander::Sound; fn main() { gander::config("Talk", 1280, 720, 0); hero = gander::animator(gander::model("assets/models/hero.glb")); hero_face = gander::face(hero); // logs "52/52 ARKit shapes, 15/15 visemes" line1 = gander::sound("assets/voice/line1.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, line1, 1.0); gander::face_emotion(hero_face, "happy", 0.7, 0.4); } let head = gander::position(gander::bone(hero, "Head")); gander::camera(head + gander::v3(0.0, 0.08, 0.7), head + gander::v3(0.0, 0.08, 0.0), 30.0); 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.5, 40.0); gander::draw_animated(hero, gander::identity()); } ``` Data: the tools read CC0 MakeHuman data from `$GANDER_FACE_DATA` (installed by `fetch_assets.py`; see `tools/face/LICENSES.md`).