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:
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
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_* (GANDER saves) |
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
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 GANDER saves.
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
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
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
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
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
f64in Goose:i * 0.7isf64; write(i as f32) * 0.7, or keep valuesf32typed. - Menus and HUD are drawn every frame you call them; stop calling to hide.
ui_panel_end()closesui_panel,ui_panel_atandui_area;ui_scrollneedsui_scroll_end(),ui_modalneedsui_modal_end().- No emoji in labels (the font has none); use words or draw icons with the primitives.