Getting started

purr is a small 2D game framework. You write your game in tigr, a small dynamic language where nearly everything is an expression. You do not need to know it deeply to begin: the snippets here cover the shapes you reach for most, and the module reference lists every function. Every module is in scope already, so the code below runs as written, with nothing to import.

Install and run

Grab the build for your platform from the download section and unzip it. You get a single purr command. Point it at a game and it opens in a window.

terminal

purr mygame.tg          // run in dev mode, with hot reload

A game is either a single .tg file or a folder holding a main.tg and its assets (images, fonts, sounds). Run purr from the project folder so the asset paths in your code resolve the way you wrote them.

The same purr command also packs and exports a finished game:

purr <game>Run in dev mode: opens a window and hot-reloads on save. purr run <game> is the same, spelled out.
purr bundle <game>Pack the game and its assets into one .purr archive. --out <file> names it.
purr build <game>Export a standalone artifact you can ship. --target host|web|windows|linux|macos|all (default host), --out <dir> sets the output base (default dist). Each target lands in its own subfolder.
purr helpThe full usage. purr version prints the version.

The game loop

You define up to three top-level functions and purr calls them. init runs once at startup, where you set things up and load assets. update and draw run every frame: update moves the world, draw paints it. Every module is in scope, so there is nothing to import.

main.tg

init := fn() {
    // once at startup: bind actions, load images, set up state
    Input.bind('jump', 'space');
};

update := fn(dt) {
    // every step: read input, move things
    if Input.pressed('jump') { /* ... */ };
};

draw := fn() {
    // every frame: paint the world
    Gfx.clear(43, 34, 28);
};

Anything that touches Gfx belongs in init or later, never at the top level of the file. The top level runs before the window and GPU exist (purr even runs it once on its own, just to read your window settings), so a Gfx.load_image up there has nothing to load into. Keep the top level for declaring globals, and load images, fonts, and shaders in init.

update is handed dt, the length of one step in seconds. purr runs update on a fixed clock at 60 steps per second, so dt is always the same 1/60. Multiplying movement by it keeps your speeds in units per second, so a value reads as "pixels per second" rather than "pixels per frame":


update := fn(dt) {
    x = x + 120 * dt;   // 120 pixels per second
};

Drawing is separate from the clock. purr renders as fast as the display allows, and each drawn frame runs however many fixed update steps it owes, which is zero, one, or a few. If the machine cannot keep up, purr runs a small number of catch-up steps and then lets the game fall behind real time (it runs a little slow) rather than freezing or spiralling. dt stays 1/60 the whole time, so your motion math never changes. If you want the clock, GameTime has it: GameTime.now(), GameTime.frame(), and the measured GameTime.fps().

You do not need all three callbacks. A still scene can be just draw. Define the ones you need and leave the rest out.

Window settings

Alongside the callbacks, a game can declare one more top-level binding: a window object. purr reads it once at startup to set up the window before the first pixel, so the title and size are right from the start with no resize flash. Every field is optional and falls back to a default.

main.tg

window := ${
    title: 'Starforge',
    width: 640,
    height: 360,
};

Like the rest of the top level, this is plain data and must not call Gfx: purr reads it before the graphics context exists. Load assets in init, set the window here.

FieldDefaultWhat it sets
title'purr'The text in the title bar.
width, height480, 270The starting window size in pixels.
resizabletrueWhether the player can resize the window.
fullscreenfalseOpen fullscreen.
vsynctrueSync drawing to the display refresh.
high_dpifalseRender at the display's true pixel density.
msaa4Antialiasing sample count. Set 1 for hard pixel-art edges.
recordingtrueThe F9 GIF-recording hotkey. Set false to reclaim F9 for the game.
screenshottrueThe F8 screenshot hotkey. Set false to reclaim F8.

Most of these are fixed once the window opens. Size and fullscreen can also change while the game runs, through the Window module (which also queries the live window and the cursor); title, vsync, resizable, high_dpi, and msaa are set here only.

Hot reload and the console

Save the file while the game is running and it picks up the change with its state intact, so you can tune a value and watch it land without restarting. This is a live patch, not a restart. It swaps in the new code, but it does not re-run init, and it does not redefine globals you have already set. So a change to a function body shows up at once, while a change to init or a new starting value for a global only takes effect when you reload for real.

Press the backtick key (`) to drop down the console. It is a live REPL into the running game: type a global's name to read its value, or x = 8 to change it while the game plays. Lines that start with / are commands.

/reloadRestart the game from disk: a fresh run that re-runs init and resets globals. This is the escape hatch when a changed init needs to take effect.
/load <path.tg>Switch to a different game file.
/build [target]Export the current game, the same as purr build. Blocks until it finishes.
/clearClear the console scrollback.
/helpList the commands.

Hot reload and the console are dev-mode tools, there when you run a .tg with purr. An exported build ships one fixed game with no console. A /reload (or a real restart) cancels any running green threads, so re-spawn in init anything that should always be running.

Drawing

Everything you draw goes through Gfx. Coordinates are in pixels, with the origin at the top left, x to the right and y down. Anywhere a coordinate is wanted you can pass a whole number or a fraction.

Colors and clearing

purr keeps one current color. You set it with Gfx.color and every draw after that uses it until you change it. Clear the frame first, usually at the top of draw, so you are not painting over the last frame.


draw := fn() {
    Gfx.clear(43, 34, 28);     // fill the frame with a background color
    Gfx.color(229, 130, 56);   // set the current color
    Gfx.rect_fill(20, 20, 64, 64);
};

Channels are red, green, blue from 0 to 255, with an optional fourth value for alpha. You can also pass a Color object in place of the three channels (see Color, below).

Shapes

Each shape has a filled form and an outline form. Outlines take an optional trailing thickness in pixels.


Gfx.rect_fill(x, y, w, h);
Gfx.rect(x, y, w, h, 2);            // 2px outline
Gfx.circle_fill(x, y, r);
Gfx.line(x1, y1, x2, y2);
Gfx.triangle_fill(ax, ay, bx, by, cx, cy);

There are ovals, arcs, and a filled pie slice (arc_fill) for gauges and cooldown clocks too. The reference lists them all.

Polygons and meshes

For any silhouette the fixed shapes do not cover, draw a polygon from a list of points. The list is either a flat [x1, y1, x2, y2, ...] (the allocation-friendly hot path for a shape you rebuild every frame) or a list of ${x, y} objects, matching Vec and Spline. polygon outlines a closed loop, polygon_fill fills it (concave shapes work, the host triangulates them), and lines draws an open connected path.


star := [ 100,20, 117,72, 172,72, 128,104, 144,156, 100,124, 56,156, 72,104, 28,72, 83,72 ];
Gfx.color(250, 210, 90);
Gfx.polygon_fill(star);

// a curve is just sampled points through lines
path := Spline.catmull_rom([ ${x:20,y:80}, ${x:80,y:30}, ${x:140,y:90} ]);
Gfx.lines(Spline.sample(path, 40), 2);

For textured or per-vertex-colored geometry (gradients, trails, soft shadows, lighting), Gfx.mesh draws a triangle array: positions, then optional uvs, colors, indices, and a bound image. With no indices the positions are a triangle soup (every three is one triangle). A per-vertex color multiplies the draw color and the GPU interpolates it across the triangle, which is how you get a gradient:


Gfx.color(255, 255, 255);                 // white draw color: vertex colors show as-is
Gfx.mesh(
    [ 100,20, 180,150, 20,150 ],          // three positions (flat, or [${x,y}])
    null,                                  // no uvs
    [ 255,40,40,255, 40,255,40,255, 40,40,255,255 ]);  // an rgba per vertex

For a regular topology you would otherwise hand-write the triangle connectivity for, the Mesh module generates the index list. Mesh.fan(n) indexes a center vertex plus an n-point ring (a filled circle or a light cone), Mesh.strip(n) a triangle strip (a trail or ribbon), and Mesh.grid(cols, rows) a deformable quad grid:


// positions = center followed by points around a circle; Mesh.fan stitches them
Gfx.mesh(ring_positions, null, ring_colors, Mesh.fan(segments));

Text

Gfx.print draws a string at a position, in the current color. purr ships with a built-in font, so text works with no asset to load. Measure a string with Gfx.text_width to center or right-align it.


msg := 'hello, purr!';

draw := fn() {
    Gfx.color(246, 201, 154);
    x := int((Gfx.width() - Gfx.text_width(msg)) / 2);
    Gfx.print(msg, x, 50);
};

Load your own font with Gfx.load_font(path) and make it current with Gfx.font(handle, size). The size is draw state, not part of the font: Gfx.font_size(n) changes it on its own and persists like the current color, and it resizes the default font too, so Gfx.font_size(32) works with nothing loaded. A vector font redraws crisply at every size. For a pixel font, pass its native size to load_font (8 for a classic 8px face); it then always rasterizes on that grid and scales as clean hard-edged blocks at any print size. To place a line by something other than its top edge, pass a vertical alignment as a string fourth argument: Gfx.print(s, x, y, 'middle') optically centers the line on y, and 'bottom' rests its baseline there, both using the font's real metrics so the position holds whatever the glyphs are.

Images and sprites

Load images in init, not in draw, so they load once instead of every frame. load_image returns a handle, a small integer you pass back to draw it.


img := null;

init := fn() { img = Gfx.load_image('cat.png'); };
draw := fn() { Gfx.draw(img, 100, 60); };

Gfx.draw can also rotate and scale: Gfx.draw(img, x, y, rot, sx, sy). To draw one tile out of a sprite sheet, use Gfx.sprite with the source rectangle.


// blit the 16x16 cell at (32, 0) in the sheet to (dx, dy)
Gfx.sprite(sheet, 32, 0, 16, 16, dx, dy);

Gfx.image_size(img) gives back ${w, h} when you need the dimensions.

Transforms

Instead of doing the math per shape, you can move, rotate, and scale the whole coordinate system. push saves the current transform and pop restores it, so you can transform one sprite and set things back for the next. The stack resets to identity at the start of each draw, so every frame starts clean.


Gfx.push();
Gfx.translate(f.x, f.y);
Gfx.rotate(f.rot);
Gfx.scale(f.scale, f.scale);
// draw around the origin: it lands at f.x, f.y, rotated and scaled
Gfx.pop();

Angles are in radians, matching Math.sin and Math.cos.

Virtual resolution

By default you draw straight to the window. Call Gfx.resolution(w, h) to draw in a fixed virtual space that scales to fill the window, so the game looks the same at any size.


init := fn() { Gfx.resolution(320, 180); };   // design once, scale everywhere

The default 'fit' mode keeps text and shapes crisp at any scale. For pixel art, Gfx.resolution(320, 180, 'integer') locks to whole-number scales and turns on pixel snapping. The reference has the full story on snapping and crisp pixel movement.

Camera

The camera is the view onto your world. It pans, zooms, rotates, follows a target, and shakes. It sits between your drawing and the screen, so you draw in world coordinates and the camera decides which part of the world is on screen.

Showing the world through it

Two calls wire the camera into the loop. Call Camera.update(dt) once in update to advance its motion (follow easing, shake decay). Call Camera.apply() once at the top of draw, and everything you draw after that is in world space. To draw a HUD or overlay back in plain screen coordinates, call Camera.reset().


init := fn() {
    Camera.follow(player);   // player is a held ${x, y}
};

update := fn(dt) {
    Camera.update(dt);
};

draw := fn() {
    Camera.apply();
    draw_world();    // world space, moved by the camera
    Camera.reset();
    draw_hud();      // back in screen space
};

You can also drive it by hand: Camera.look(x, y) centers it on a world point, Camera.move(dx, dy) pans, Camera.zoom(z) scales (1 is native, 2 zoomed in, 0.5 out), and Camera.rotation(a) turns it in radians. A direct setter is overridden by the next update while a follow target is active, so use these for a camera you steer yourself.

Following a target

Camera.follow(target) tracks a moving object, easing its focal point toward the target each update so it trails smoothly rather than snapping. Set how lazy it is with Camera.smoothing(tau), a time constant in seconds: larger is lazier, 0 snaps instantly. It is frame-rate independent.

follow holds a reference to the object and reads its x and y each frame, so keep the same object and write its fields in place (player.x = ...). Reassigning it (player = Vec.add(player, step)) builds a new object and leaves the camera following the old one.

Two more knobs shape the follow: Camera.deadzone(w, h) gives the target a box it can roam inside before the camera moves, and Camera.bounds(x, y, w, h) clamps the view to the level so it never shows past the edge.


init := fn() {
    Camera.smoothing(0.12);
    Camera.deadzone(64, 48);
    Camera.bounds(0, 0, level.w, level.h);
    Camera.follow(player);
};

Camera.lookahead(distance, tau) leads the view ahead of the target in its direction of travel, so the player sees more of where they are going. It infers the velocity from the followed target's own motion, so it needs nothing beyond follow: distance is the most it leads in screen pixels, and tau (default 0.3) is how far ahead in time it aims, so the lead grows with speed and eases in over that same window. When the target stops the lead is held where it landed rather than recentered, so stop-start movement does not make the camera lurch. It pairs naturally with a deadzone.

For a pixel-art game (integer resolution or pixel snapping), use Camera.smoothing(tau, 'out'). It tracks tightly while the target moves and eases only as it settles, so the pixel grid does not shimmer. The Gfx reference covers the pixel-perfect details.

Shake

Camera.shake(strength, duration) adds a quick, decaying shake for impacts: a peak offset of strength pixels over duration seconds, fading to zero. It rides on top of the follow or manual position and leaves the focal point alone once it decays. A bigger shake during a small one wins, so a solid hit always registers.


update := fn(dt) {
    Camera.update(dt);
    if took_hit { Camera.shake(8, 0.3) };
};

Screen and world

Because the camera moves the world, a screen position (like the mouse) is not a world position. Convert between them: Camera.to_world(sx, sy) turns a screen point into world space, and Camera.to_screen(wx, wy) goes the other way. Camera.visible() gives the world rectangle on screen right now, which is what you cull against.


// aim at the cursor, in world space
target := Camera.to_world(Input.mouse_x(), Input.mouse_y());

// cull off-screen entities: one view rect, then a cheap overlap test each
view := Camera.visible();
for (e, entities) {
    if Collide.aabb(view, e.box) { draw_entity(e) };
};

to_world, to_screen, and visible each build a new object, so call them a few times a frame (mapping the mouse, grabbing the cull rect), not once per entity in a tight loop.

Input

Input reads the keyboard, mouse, gamepads, and touch. It splits into two families that share one set of key names.

Actions and raw keys

An action is a logical name your game asks about, like 'jump' or 'left'. It resolves through a binding table, so the player can remap it. A raw key reads one physical key directly, which is what you want for a debug toggle or a "press W" prompt. Most game code should use actions.

Both families answer the same three questions: held now, pressed (went down this frame), and released (went up this frame).

ActionsRaw keysTrue when
Input.down(a)Input.key(k)held right now
Input.pressed(a)Input.key_pressed(k)went down this frame
Input.released(a)Input.key_released(k)went up this frame

pressed and released are true for the single frame the state changes, so they fit do-this-once actions like jump or fire without you tracking the previous state. down is true every frame the input is held, for continuous movement.


update := fn(dt) {
    if Input.down('right')   { x = x + speed * dt };   // every frame held
    if Input.pressed('jump') { vy = -jump };           // once per tap
    if Input.key_pressed('tab') { debug = !debug };    // a specific physical key
};

Binding actions

Every action starts with a sensible default, so a game works with no setup. Call Input.bind to change one. Pass a single key or a list, and the action fires when any key in the list is down.


init := fn() {
    Input.bind('jump', ['space', 'z']);   // either key jumps
    Input.bind('fire', 'x');
};

The arrow keys and WASD already drive 'left' / 'right' / 'up' / 'down', and a connected controller drives them too, so a keyboard game gains gamepad support with no changes.

The mouse

The mouse lives in Input. Its position is in the same coordinate space you draw in.


if Input.mouse_pressed('left') {
    place_tower(Input.mouse_x(), Input.mouse_y());
};

button is 'left', 'right', or 'middle'. The wheel comes back as a per-frame delta from Input.mouse_wheel_y(), so you add it up rather than read a position. Touch and gamepads round out the module; the reference covers them.

Sound

Audio loads and plays sound. Sound flows along a small graph you wire up: a sound plays as a voice, voices route through a bus, and every bus feeds the master output. Volumes are perceptual, so a halfway value sounds halfway, not nearly silent.

The sound graph

Four things, all integer handles like images:

  • A sound is a loaded asset: a clip in memory, or a streamed file.
  • A voice is one playing instance of a sound. play returns a voice you control.
  • A bus is a group that voices route through and effects attach to. A sound is bound to a bus when you load it.
  • The master bus is the final output. Every bus feeds it.

You shape a group by changing its bus (its volume, its effects), not by moving voices around. Put a lowpass on the music bus and everything on it muffles at once, which is how games actually use effects.

Loading and playing

Load sounds in init. Audio.load decodes a short clip fully into memory, for hits and jumps that fire instantly and overlap freely. Audio.load_music streams a long file (music, ambience) instead of holding it all in memory. Both take an optional bus to route to.


jump  := null;
theme := null;

init := fn() {
    jump  = Audio.load('sfx/jump.wav', 'sfx');
    theme = Audio.load_music('music/theme.ogg', 'music');
    Audio.play(theme, ${ loop: true, fade: 1.0 });   // start the track, looping in
};

Audio.play(sound) plays it once and hands back a voice. The flat form play(sound, volume, pitch, pan) is the hot path for one-shots, and a little random pitch keeps a repeated sound from turning into a machine gun.


update := fn(dt) {
    if Input.pressed('jump') {
        Audio.play(jump, 1.0, 0.95 + Random.float() * 0.1);   // slight pitch variation
    };
};

For a track or looping ambience, pass the options table (loop, fade, volume, pitch, pan) as shown above. Steer a live voice with Audio.volume, pitch, pan, pause, resume, and stop (which takes an optional fade-out). A voice handle retires when the sound ends or you stop it, so later calls against it are safe no-ops and Audio.playing(voice) reads false.

Buses and volume

A bus is a named group. Get or create one with Audio.bus(name); asking for the same name again returns the same bus, so two parts of your game that want 'music' share it. Set a group's level with Audio.bus_volume(bus, v, fade) and the whole mix with Audio.master(v, fade). The optional fade ramps the change over that many seconds, which is what ducking and crossfades are built from.


sfx   := Audio.bus('sfx');
music := Audio.bus('music');

// pause menu: duck the music, leave sound effects full
Audio.bus_volume(music, 0.3, 0.25);

Volumes are perceptual loudness from 0 to 1, so equal steps sound like equal steps: 0.5 is a clear drop, not barely audible. Pan runs -1 (hard left) to 1 (hard right), and pitch is a rate multiplier, where 2 is an octave up and 0.5 an octave down. To slide any of these every frame, drive them with Tween.with (see Tween).

Effects

An effect attaches to a bus and colors everything flowing through it: a lowpass to muffle, a reverb for space, plus EQ, delay, compressor, and distortion. Declare it in init, before that bus plays anything, then tweak it live.


init := fn() {
    music  = Audio.bus('music');
    muffle = Audio.effect(music, ${ type: 'lowpass', cutoff: 20000 });   // transparent to start
};

dive := fn() {
    Audio.effect_set(muffle, 'cutoff', 500, 0.5);   // sweep down to underwater over 0.5s
};

Audio.effect_set changes any parameter live, with an optional fade for smooth sweeps; Audio.effect_remove drops it. The reference lists every effect type and its parameters.

The natural way to use effects is to load related sounds onto a shared bus and put one effect there. The per-sound play path stays cheap and the effect work happens once on the group, not per voice.

Motion and juice

purr leans on green threads for anything that plays out over time. A green thread is a lightweight coroutine you start with go. Inside one you can wait, and the code reads top to bottom instead of as a state machine.

Green threads and go

update and draw run on the main thread and cannot wait. To play something out over time you spawn a green thread with go, and inside it you can call GameTime.wait(seconds) or the Tween and Animation helpers that block until they finish. Each call pauses just that thread, so a sequence reads as plain top-to-bottom code. Green threads are a tigr language feature; the concurrency docs cover them in full, including spawn, select, and channels.


go fn() {
    Tween.to(door, 'y', 0, 0.4, 'out_quad');   // slide open
    GameTime.wait(0.5);                         // hold
    Tween.to(door, 'y', 64, 0.3, 'in_quad');    // and close
};

You can spawn a green thread from update; it begins on the next frame. A reload cancels running threads, so re-spawn long-lived ones in init.

Tween

Tween animates a value over time along an easing curve. The everyday form is Tween.to, which animates a field of an object from its current value to a target.


player := ${ x: 0, y: 0 };

go fn() {
    Tween.to(player, 'x', 300, 0.5, 'out_quad');   // slide in
    Tween.to(player, 'y', 100, 0.3);               // then drop (linear by default)
};

The last argument is the easing curve, by name or as a function, and defaults to linear.

Tween.to writes the field for you, but sometimes the value you want to move is not a plain object field. Tween.with covers that: you give it a start and end number, and each frame it calls a function of yours with the current value, so you decide where it goes.


go fn() {
    // fade the master volume from 1 down to 0 over 0.4s
    Tween.with(1, 0, 0.4, 'out_quad', fn(v) { Audio.master(v) });
};

Tween.over is the lowest-level form. It hands your function the eased progress t each frame, running 0 to 1, and you read whatever you want from it. That lets one curve drive several values at once.


go fn() {
    Tween.over(0.6, 'out_back', fn(t) {
        pos   = Vec.lerp(start, target, t);   // move a position
        alpha = t;                            // and fade in, on the same curve
    });
};

Starting a new Tween.to on the same object and field cancels the one already running on it, so you can retarget a motion mid-flight and it carries on from where it is with no jump. To stop a whole go sequence, keep its handle and cancel it.


seq := null;

update := fn() {
    if Input.mouse_pressed('left') {
        if seq != null { cancel(seq) };   // abandon the previous run
        seq = go fn() {
            Tween.to(coin, 'r', 80, 0.6, 'out_back');
            GameTime.wait(1);
            Tween.to(coin, 'r', 0, 0.25, 'in_quad');
        };
    };
};

Tween and Animation.do block, so they only work inside a go block. Calling one straight from update or draw raises, the same as a bare wait would. Wrap it in go and it runs on its own thread.

Easing curves

An easing curve shapes how a value moves from start to finish: slow then fast, a little overshoot, a bounce at the end. Tween takes a curve by name, and Ease holds them all as plain functions you can call yourself.

The families are quad, cubic, quart, quint, sine, expo, circ, back, elastic, and bounce. Each comes in an in, out, and in_out variant, plus plain linear.


y := 100 + Ease.out_bounce(t) * 200;

Pass 'out_back' for a touch of overshoot, 'out_bounce' for a landing bounce, or 'in_out_sine' for a smooth ease at both ends.

Animation

Animation plays a flip-book of frames, either a list of images or rectangles cut from one sprite sheet. You describe it once and draw it every frame, and it works out which frame to show from elapsed time.


walk := null;

init := fn() {
    frames := [Gfx.load_image('walk0.png'), Gfx.load_image('walk1.png'), Gfx.load_image('walk2.png')];
    walk = Animation.new(frames, 12);   // 12 fps, loops
};

draw := fn() {
    Animation.draw(walk, player.x, player.y);
};

The speed is either an fps number or a list of per-frame seconds. The mode is 'loop' (the default), 'once' (stop on the last frame), or 'pingpong'. Build from a sheet with Animation.grid (a regular grid of cells) or Animation.sheet (explicit rectangles).

For a crowd, clone keeps them from marching in lockstep: it shares the frames and gives each unit its own clock, so a hundred clones cost a hundred tiny clock objects and nothing more.


idle := Animation.grid(sheet, 16, 16, 0, 4, 8);   // define once

spawn := fn(x, y) {
    enemies += ${ x: x, y: y, anim: Animation.clone(idle, rand() * idle.total) };
};

In a cutscene, Animation.do plays one full cycle and waits for it, so it sits inside a go block next to Tween and GameTime.wait as one more step.

Paths and curves

Spline builds a smooth path through a set of points and moves something along it: a patrolling enemy, a flying camera, a guided projectile. You build the curve once, then ask it for a position. There are three kinds, each from a list of ${x, y} points and an optional closed flag to loop it.

Spline.catmull(points)A smooth curve that passes through every point. The everyday choice for a hand-placed route.
Spline.bezier(points)Joined cubic Beziers from anchor, control, control, anchor, ... for precise control over the shape.
Spline.linear(points)A straight polyline through the points, for sharp corners.

To move at a steady speed, drive the curve by arc length with Spline.at(s, u), where u runs 0 to 1 along the whole length evenly. (The plainer Spline.point(s, t) takes a raw parameter instead, which bunches up where the control points do, so speed is uneven.) Advance u by distance over length each frame, and read the facing direction with Spline.heading(s, u).


path := null;
u    := 0.0;

init := fn() {
    path = Spline.catmull([ ${ x: 40, y: 40 }, ${ x: 200, y: 80 },
                            ${ x: 300, y: 220 }, ${ x: 80, y: 260 } ], true);   // closed loop
};

update := fn(dt) {
    u = Num.wrap(u + 60 * dt / Spline.length(path), 0, 1);   // 60 px/s along the path
};

draw := fn() {
    p := Spline.at(path, u);
    Gfx.push();
    Gfx.translate(p.x, p.y);
    Gfx.rotate(Spline.heading(path, u));   // turn to face the way it's going
    Gfx.triangle_fill(8, 0, -6, -5, -6, 5);
    Gfx.pop();
};

To draw the curve itself, Spline.sample(s, n) hands back n evenly spaced points you can join with lines. A spline is plain data baked once at construction, so build it in init and hold it.

Particles

An emitter spawns short-lived particles you describe once, then update and draw each frame. Start from a preset and tweak it:


fire := null;
init := fn() { fire = Particle.fire(${ at: ${ x: 160, y: 200 } }) };
update := fn(dt) { fire.update(dt) };
draw := fn() { Gfx.clear(10, 10, 16); fire.draw() };

A continuous emitter has a rate per second and streams; a burst emitter (rate: 0) fires on demand. Particles live in world space, so a moving emitter leaves a trail; moveto follows something, and emit_at pops a burst at a point.


pop := Particle.burst();
// ...on a hit:
pop.emit_at(enemy.x, enemy.y, 24);

The presets are Particle.fire, smoke, sparks, trail, and burst; each takes an over object that overrides any field (position, colour, speed, gravity, size and alpha over life, blend, the look). The pool is allocated once and reused, so a steady stream costs nothing per frame. The reference lists the full config.

Math and geometry

A handful of small modules cover the math a game reaches for constantly. Math already has abs, min, max, clamp, sqrt, and trig; these fill the gaps.

Num

Scalar helpers beyond Math.

lerp(a, b, t)Blend from a to b as t runs 0 to 1.
unlerp(a, b, v)The inverse: where v sits between a and b.
remap(v, a0, a1, b0, b1)Map v from one range to another.
approach(v, target, step)Move v toward target by at most step, never overshooting.
wrap(v, lo, hi)Wrap v into [lo, hi), for angles and screen wrap.
snap(v, step)Round to the nearest multiple of step, for grid alignment.

hp_frac := Num.unlerp(0, max_hp, hp);           // 0..1 health fraction
bar_w   := Num.remap(hp, 0, max_hp, 0, 200);    // mapped to a 200px bar
facing  := Num.approach(facing, want, turn * dt);
gx      := Num.snap(mouse_x, 16);               // snap to a 16px grid

Vec

Two-dimensional vectors as the object ${x, y}. Every operation returns a fresh vector and never changes its inputs. Build with Vec.new, combine with add / sub / scale, measure with len / dist / dot, and transform with normalize / rotate / clamp_len.


to_player := Vec.sub(player.pos, enemy.pos);
if Vec.len2(to_player) < range * range {              // in range, no square root
    step := Vec.scale(Vec.normalize(to_player), speed * dt);
    enemy.pos = Vec.add(enemy.pos, step);
};

Because each call returns a new object, a per-entity step like this allocates one vector per entity per frame. That is fine for hundreds of entities. For thousands, keep the hot path in plain x and y floats and use Vec for the rest.

Passing a vector you already hold costs nothing; only building a new one allocates.

Collide

Overlap tests and ray casts over simple shapes: a rectangle is ${x, y, w, h}, a circle is ${x, y, r}, a point is a vector ${x, y}. The overlap tests return a bool and allocate nothing.


if Collide.aabb(player.box, pickup.box) { collect(pickup) };

mouse := ${ x: Input.mouse_x(), y: Input.mouse_y() };
if Collide.point_circle(mouse, boss.body) { hovering = true };

A ray cast (ray_rect, ray_circle) returns a hit object ${t, x, y, nx, ny} or null on a miss, with t the distance along the ray and nx, ny the surface normal at the contact.


hit := Collide.ray_circle(eye, look, target.body);
if hit != null {
    Gfx.line(eye.x, eye.y, hit.x, hit.y);   // line of sight to the contact point
};

Move-and-slide, the resolver that walks a box through solid tiles, also lives in Collide. It is covered under Worlds and tiles, next to the grids it reads.

Rect

Rectangle arithmetic over the same ${x, y, w, h} shape Gfx.rect and Collide use (top-left corner, then size). This is the math Collide does not cover: where Collide.aabb answers "do these overlap" with a bool, Rect.intersect hands back the overlap rectangle. Every function is pure, returning a fresh rect and never changing its inputs.

Rect.new(x, y, w, h)Build a rect. Also from_points (two corners) and from_center.
Rect.intersect(a, b)The overlapping rect, or null if they do not overlap.
Rect.union(a, b)The smallest rect containing both.
Rect.contains_point(r, p)Point inside the rect; contains(r, inner) for a rect inside a rect.
Rect.inflate(r, dx, dy)Grow (or shrink, if negative) around the center. translate moves it.
Rect.clamp_point(r, p)The nearest point inside r to p, to confine a position.

It chains nicely with the pipe operator when a subject-first read is clearer, and pairs with Camera.visible() for cull math:


clip := view |> Rect.inflate(8, 8) |> Rect.intersect(screen);

// keep the player box inside the level bounds
c := Rect.clamp_point(level, ${ x: player.x, y: player.y });
player.x = c.x; player.y = c.y;

There is also right / bottom / center / area for reading a rect, and split_x / split_y for carving one in two (handy for laying out a HUD). The reference lists them all.

Color

A color is the object ${r, g, b} with channels 0 to 255. It drops straight into Gfx.color in place of three numbers, with an optional trailing alpha. Build one whichever way suits:

  • Color.rgb(r, g, b) from plain channels.
  • Color.hsv(h, s, v) from hue in degrees (0 to 360) with saturation and value in 0 to 1. Handy for cycling hues or picking a tint by feel.
  • Color.hex('#ff8800') from a hex string, the short '#f80' form included.
  • Color.lerp(a, b, t) blends two colors, with t running from 0 (all a) to 1 (all b).

c := Color.hsv(210, 0.5, 1.0);   // a soft blue
Gfx.color(c);                     // pass the color object straight in
Gfx.color(c, 128);                // ...with a trailing alpha

lerp is how you cross-fade a color over time. Here pulse swings between 0 and 1 with the clock, so the fill flashes between a dark base and red in time with it:


draw := fn() {
    pulse := (Math.sin(GameTime.now() * 6) + 1) / 2;   // 0..1, back and forth
    flash := Color.lerp(Color.hex('#202028'), Color.hex('#ff5050'), pulse);
    Gfx.color(flash);
    Gfx.rect_fill(20, 20, 80, 80);
};

Worlds and tiles

These modules build levels and move things through them: a grid to hold the world, a Tiled importer for hand-authored maps, an autotiler for terrain that draws itself, move-and-slide collision, and noise for procedural worlds. They share one idea, the cell grid, so they fit together.

Reading data files

A level you make in an external editor, a CSV of dialogue, a packed binary table: Assets reads any file that ships with your game and hands back its raw text or bytes. It uses the same bundling-aware source as the image, font, and sound loaders, so a file that loads in dev also loads from an export and on the web, with no filesystem call of your own.


level := JSON.parse(Assets.text('./level.json'));     // JSON, decoded by the ambient JSON
rows  := String.lines(Assets.text('./spawns.csv'));   // CSV / newline data
blob  := Assets.bytes('./palette.bin');               // raw bytes for binary formats
r     := Bytes.read_u8(blob, 0);
if Assets.exists('./mods/extra.json') { ... };        // gate an optional file

Assets.text gives a UTF-8 string and Assets.bytes a raw buffer (read it with the Bytes.read_u* family); Assets.exists checks for a file without reading it. Read in init, not per frame. The format is up to you: Assets is just the reader, and tigr's ambient JSON, String, and Bytes do the decoding. For Tiled maps specifically, Tilemap.load (below) reads the file for you.

Paths are relative to the game, so this needs a folder game (a directory with main.tg and its data beside it) rather than a single .tg file, so the data travels with an export.

Grids

A Grid is a dense 2D array of cells: the store under a tile layer, a board game, a cellular automaton, or runtime terrain. It is plain serializable data with pure free functions over it. A cell holds any value you like (a number, a bool, a string), and new takes the fill that becomes the grid's default.


world := Grid.new(32, 24, false);   // 32x24, every cell false
world.cell_w = 16;                  // place it in world space: each cell is 16x16 px
world.cell_h = 16;

Grid.set(world, 5, 5, true);        // write a cell (a no-op if out of bounds)
solid := Grid.get(world, 5, 5);     // read it; get(g, c, r, oob) returns oob off-grid

Setting cell_w / cell_h (and optionally ox / oy) places the grid in the world, which unlocks the projection helpers: Grid.cell_at(g, wx, wy) finds the cell under a world point, Grid.cell_world and Grid.cell_rect go back the other way. get takes a fourth argument for what out-of-bounds reads as, which is the clean way to treat the world edge as a wall.


// paint the cell under the mouse
cell := Grid.cell_at(world, Input.mouse_x(), Input.mouse_y());
if Input.mouse_down('left') { Grid.set(world, cell.c, cell.r, true) };

// draw only the visible window, culled
Grid.each_in(world, 0, 0, 31, 23, fn(c, r, v) {
    if v { box := Grid.cell_rect(world, c, r); Gfx.rect_fill(box.x, box.y, box.w, box.h) };
});

For automata, Grid.copy makes a deep clone so you can read one buffer and write the next, and Grid.neighbors(g, c, r, diag) lists the in-bounds neighbours of a cell. each walks every cell, each_in walks a clamped box (the culled draw path), and from(cols, rows, f) builds a grid by calling f(c, r) per cell, for a procedural init.

Tilemaps

For an authored level, draw it in Tiled and load the JSON export. Tilemap.load parses it into a record: tile layers become grids of tile indices, tilesets resolve into one flat tile table, and the gid flip and rotation bits decode into a side table. You then draw its layers and query it for collision, spawns, and properties.


map := null;

init := fn() { map = Tilemap.load('./level.tmj'); };

draw := fn() {
    Camera.apply();
    Tilemap.draw(map, Camera.visible());   // every tile layer, culled to the view
};

Tilemap.draw paints every tile layer in order; pass a world-space Rect (Camera.visible() is the usual one) to cull to what is on screen. To slip your own sprites between layers, draw them one at a time with Tilemap.draw_layer(map, 'bg', view), your entities, then draw_layer(map, 'fg', view).

Collision is kept separate (it is just move-and-slide over a layer's grid), so a tile is solid because you say so, usually from a custom property you set in Tiled. Tilemap.tile_prop reads that property, the typed, named replacement for old bit-flag tricks.


coll := map.tile_layers.collision;     // the collision layer is a Grid

solid := fn(c, r) {
    k := Grid.get(coll, c, r, -1);     // out of bounds -> -1
    if k == -1 { true }                // the map edge is a wall
    else if k == 0 { false }           // 0 = empty cell
    else { Tilemap.tile_prop(map, k, 'solid', false) }   // a Tiled tile property
};

update := fn(dt) {
    m := Collide.slide_grid(coll, player, dx * dt, dy * dt, solid);
    player.x = m.x; player.y = m.y;
};

Object layers carry spawns, doors, and triggers. Tilemap.object(map, 'objects', 'spawn') finds one by name and Tilemap.objects(map, 'objects') lists a whole layer, each object an ${name, type, x, y, w, h, ...}. The record also gives you map.px_w / map.px_h for Camera.bounds. The reference covers the full query surface and exactly which Tiled features import.

Autotiling

For terrain the player paints or you generate at runtime, Autotile draws a boolean grid as a finished surface, picking the right edge and corner tile per cell so coastlines and walls join cleanly. It uses the dual-grid technique, so a material needs only 16 tiles, and every drawn tile sits on the junction of four cells.


land := Grid.new(40, 24, false);   // true = this material is present
land.cell_w = 16; land.cell_h = 16;

grass := null;
init := fn() { grass = Autotile.new(sheet, 16, 16); };   // a 16-tile sheet

draw := fn() { Autotile.draw(grass, land); };            // draws itself from the grid

The one boolean grid is the single source of truth: edit a cell and the tiling updates for free next frame. Autotile.draw takes an optional cull Rect like the tilemap. For layered terrain (sand under grass under rock), make one autotiler per material and paint them back to front with Autotile.draw_all([sand, grass, rock], grid). The opts on new set how the sheet is laid out and what counts as the material; the reference and the autotile showcase have the details.

Move and slide

Collide.slide is the resolver that moves a box through solid tiles: it pushes the box out of anything solid and slides it along walls, the motion a top-down or platformer character wants. It is predicate-driven, so it reads neither a grid nor a tilemap directly. You hand it the box, the movement for this frame, and a solid(c, r) function that decides each cell. Collide.slide_grid is the common form that reads the cell geometry off a Grid (or a placed tilemap layer).


player := ${ x: 48.0, y: 48.0, w: 12.0, h: 14.0 };   // the box: position and size

solid := fn(c, r) { Grid.get(world, c, r, true) };   // edge reads as solid

update := fn(dt) {
    dx := 0.0; dy := 0.0;
    if Input.down('left')  { dx = dx - SPEED };
    if Input.down('right') { dx = dx + SPEED };
    if Input.down('up')    { dy = dy - SPEED };
    if Input.down('down')  { dy = dy + SPEED };

    m := Collide.slide_grid(world, player, dx * dt, dy * dt, solid);
    player.x = m.x; player.y = m.y;        // the corrected position
};

The result carries more than the new position. m.grounded is true when something solid is directly below (the jump gate for a platformer), m.hit_x / m.hit_y say the move was stopped on that axis this frame (kill your velocity when you bonk a wall or land), and left / right / top / bottom are the live per-side contacts.


// platformer step: gravity, jump off the ground, stop falling on a hit
vy = vy + GRAVITY * dt;
if grounded && Input.pressed('jump') { vy = -JUMP };
m := Collide.slide_grid(world, player, dx * dt, vy * dt, solid);
player.x = m.x; player.y = m.y;
if m.hit_y { vy = 0.0 };        // landed or hit the ceiling
grounded = m.grounded;

Your solid predicate can return more than a bool: 'one_way' marks a drop-through platform (solid only from above), which is how you build floating ledges. The reference covers one-way platforms, the substep that stops a fast body tunnelling, and the contact skin.

Procedural noise

For terrain, scatter, and organic variation, Num.noise is coherent noise: a smooth field where nearby points have nearby values (unlike rand(), which is unrelated each call). Sample it at 1, 2, or 3 dimensions by argument count, and it returns roughly -1 to 1. Remap to 0..1 with v * 0.5 + 0.5.


// fill a grid with an elevation field, then read it back
world := Grid.from(80, 50, fn(c, r) {
    Num.fbm(c * 0.08, r * 0.08) * 0.5 + 0.5    // 0..1 elevation
});

Num.fbm (fractal brownian motion) layers several octaves of noise for natural-looking detail, the everyday terrain knob. Scaling the input coordinate sets the feature size: a smaller multiplier means larger, smoother landmasses. Num.value_noise is a blockier, cheaper variant. To get the same world back every run, fix the field with Num.noise_seed(n) in init; until you call it, it follows the host seed.

The noise → grid → autotile path is the whole runtime-terrain pipeline: a noise field fills a grid, a solid test turns elevation into a boolean per material, and Autotile draws each band's coastline. The noise showcase builds a continent end to end this way.

Scenes and state

A game is rarely one screen. Scene is purr's state manager: a stack of game states (title, play, pause, game over), each with its own update and draw, so you switch screens without one giant if state == ... woven through every callback.

The scene stack

Scene.new() builds one machine. You hold it as top-level state and route the host's three callbacks through it, and the machine sends each one to whichever scene is on top.


game := Scene.new();

init   := fn()   { game.switch(menu) };
update := fn(dt) { game.update(dt) };
draw   := fn()   { game.draw() };

A scene is just a plain object with optional hooks. There is no registration and no base type. Each hook receives the machine first, and tigr drops extra arguments, so a hook names only the parameters it uses.


menu := ${
    enter:  fn(sm, arg) { ... },   // became active; arg is what the caller passed
    update: fn(sm, dt)  { ... },   // each frame, only while this scene is on top
    draw:   fn(sm)      { ... },   // each frame, drawn in stack order
    leave:  fn(sm)      { ... },   // removed from the stack
};

Four operations move the stack, each firing the matching hooks:

switch(scene)Leave the whole stack and enter scene, so the stack becomes just it.
push(scene)Pause the current top and lay scene over it.
pop(result)Leave the top and resume the scene it reveals, handing it result.
replace(scene)Swap just the top for scene.

Only the top scene updates, so a covered scene's logic is frozen. Its draw still runs though, so a pushed scene composites over the frozen frame beneath it. That is exactly what a pause menu over a still gameplay frame wants.


play  := ${ update: fn(sm, dt) { if Input.pressed('pause') { sm.push(pause) } } };

pause := ${
    update: fn(sm, dt) { if Input.pressed('pause') { sm.pop() } },
    draw:   fn(sm)     { dim_screen(); Gfx.print('PAUSED', x, y) },
};

Mark a scene opaque: true to hide everything beneath it, for a full-screen state (a settings page, a game-over screen) that has no reason to pay for drawing the scenes under it.

A scene's world

Hold a scene's own state as locals captured in a constructor, not as fields on the scene object (tigr has no implicit this, so a scene cannot see itself mid-construction). Switching away drops the world, and entering rebuilds it fresh, with no globals and no manual teardown.


make_play := fn() {
    player  := null;          // private to this scene
    enemies := [];
    ${
        enter: fn(sm, arg) {
            player  = make_player();
            enemies = [make_enemy(40), make_enemy(280)];
        },
        update: fn(sm, dt) { player.update(dt); for (e, enemies) { e.update(dt) } },
        draw:   fn(sm)     { for (e, enemies) { e.draw() }; player.draw() },
    }
};
play := make_play();

The same machine serves a single entity's brain: hold one Scene.new() per enemy and call replace to change states, never push, since an entity has no overlay stack. Each state is the same shape as a screen scene, reaching the entity by closing over it.

Transitions

Each stack operation takes an optional transition as its last argument, to cover the swap with an effect instead of cutting hard between scenes.


game.switch(play, null, Scene.fade(0.4));
game.push(pause, null, Scene.wipe(0.3, 'down'));

Scene.fade(dur, color) dips to a color and back, swapping at the covered midpoint, and Scene.wipe(dur, dir, color) slides a solid bar across. Both run on the real clock, so a transition plays at a steady pace whatever the game clock is doing, and while one runs the world holds under the cover.

A machine survives hot reload, but editing a scene's source rebinds its name to a fresh object while the live stack still holds the old one. Re-trigger the switch, or /reload, to pick up an edit to the scene on screen.

Time and pause

GameTime is a game's window onto simulation time. Past the frame reads it has always had (now, dt, fps, frame), it owns a few timelines: clocks the game can pause and scale on their own. This is what a pause menu, slow motion, and bullet time ride on.

Two clocks

There are two from the start. The game clock is what GameTime.now() and GameTime.dt() read, and what Tween and Animation follow, so pausing or scaling it freezes or stretches the whole world. The real clock (GameTime.real) always runs at rate 1 and never pauses, for the UI and transitions that must keep moving while the world is frozen.

Pausing the world


GameTime.pause();     // freeze the game clock
GameTime.resume();    // run it again from where it stopped
GameTime.paused();    // -> Bool

Pausing does not suspend any green thread directly. It freezes the time they wait against: a Tween, an Animation, or a GameTime.wait measures progress from the game clock, so a frozen clock holds them exactly in place, and resuming continues from the same point with no jump. That is why a pause scene reads as a couple of lines:


pause := ${
    enter: fn(sm) { GameTime.pause() },      // freeze the world
    leave: fn(sm) { GameTime.resume() },     // and run it again on pop
    update: fn(sm, dt) { if Input.pressed('pause') { sm.pop() } },
    draw:  fn(sm)      { dim_screen(); Gfx.print('PAUSED', x, y) },
};

The menu stays responsive because its update runs on real frame time, and any animation it plays can run on GameTime.real.

This is why GameTime.wait(secs), not the built-in wait(secs), is the one to reach for inside a go block. The built-in stays on real time and ignores pause; GameTime.wait respects pause and scale, so a cutscene or a tween sequence freezes cleanly when the world does. Keep the built-in wait for a delay that should ignore pause, like a UI beat.

Slow motion and local clocks


GameTime.scale(0.3);   // slow motion
GameTime.scale(1.0);   // normal
GameTime.scale(2.0);   // double speed

Scale stretches the game clock without touching real time. Motion that integrates GameTime.dt() scales for free, while motion that uses the raw dt passed to update does not, which is the right default for anything that should ignore slow motion.

For a system that wants its own sense of time, GameTime.clock() makes an independent timeline you can pause and scale on its own (a combat slow-mo that does not touch the UI). Tween and Animation take an optional trailing clock to run on it instead of the game clock.


combat := GameTime.clock();
combat.scale(0.4);                                          // slow just this system

Tween.to(hud.alpha, 'a', 1, 0.3, 'linear', GameTime.real); // UI tween, ignores pause

Interface

Gui is purr's user-interface layer. A container holds widget objects (buttons, labels, panels) and you route update and draw through it. It hit-tests the mouse and touch, fires per-widget callbacks, and offers opt-in keyboard and gamepad focus, so a menu or settings screen reads like the rest of the game.

Building a UI

Gui is retained, not immediate: you build the UI once, hold it as top-level state, and mutate it over time, the same shape as a Scene or an entity.


ui := Gui.new();

init := fn() {
    ui.add(Gui.panel(${ x: 40, y: 40, w: 240, h: 160 }));
    ui.add(Gui.label(${ text: 'Settings', x: 40, y: 54, w: 240, align: 'center' }));
    ui.add(Gui.button(${ text: 'Resume', x: 64, y: 96, w: 192, h: 32,
                         on_click: fn(self) { game.pop() } }));
};

update := fn(dt) { ui.update(dt) };
draw   := fn()   { ui.draw() };

add returns the widget handle, so you can hold it and mutate its fields directly, and the change is live next frame (btn.text = 'Paused', btn.visible = false). Draw and focus order is add order, so later widgets sit on top.

Widgets and callbacks

Build widgets with the named sugar Gui.button / Gui.label / Gui.panel, or the generic Gui.item(spec) they forward to. Every spec field is optional and falls back to the theme or a type default; the ones you reach for most are x y w h, text, color, align, and visible. Each widget also takes event callbacks, each handed the widget as self:

on_click(self)Released inside after pressing inside, or confirm pressed while the widget is focused.
on_hover(self, over)The pointer enters (over true) or leaves (false).
on_press(self) / on_release(self)The pointer presses inside, or a press on this widget releases.
on_focus(self, focused)Focus is gained (focused true) or lost (false), under keyboard or gamepad navigation.
on_update(self, dt)Every frame, for custom per-widget logic.

For a widget the built-in looks do not cover, supply a draw override. The container still hit-tests it and fires its callbacks, so an unknown type with a draw renders however you like. Read self._hover, self._press, and self._focus for interaction states. A second override, hit(self, mx, my) -> Bool, replaces the default rectangular hit-test for a widget that is not a rectangle, so the pointer only counts as over it where you say.


ui.add(Gui.item(${
    type: 'badge', x: 20, y: 20, w: 48, h: 48,
    on_click: fn(self) { ... },
    on_focus: fn(self, focused) { if focused { Audio.play(blip) } },
    draw: fn(self) {
        c := if self._hover || self._focus { 255 } else { 180 };
        Gfx.color(c, c, 60);
        Gfx.circle_fill(self.x + 24, self.y + 24, 24);
    },
    hit: fn(self, mx, my) {                          // a round target, not the box
        Collide.point_circle(${ x: mx, y: my }, ${ x: self.x + 24, y: self.y + 24, r: 24 })
    },
}));

Form widgets

Beyond buttons and labels, the kit has the controls a settings screen or form needs: checkbox, slider, text_input, a scrollable scroll container, and a dropdown. Each is sugar over Gui.item, carries its own state field, and takes an on_change (and the text input an on_submit).


ui.add(Gui.checkbox(${ text: 'Fullscreen', x: 32, y: 40, checked: false,
                       on_change: fn(self, on) { Window.fullscreen(on) } }));

ui.add(Gui.slider(${ x: 32, y: 72, w: 160, value: 70, min: 0, max: 100,
                     on_change: fn(self, v) { Audio.volume('music', v / 100) } }));

ui.add(Gui.text_input(${ x: 32, y: 104, w: 160, placeholder: 'name',
                         on_submit: fn(self) { save(self.text) } }));

ui.add(Gui.dropdown(${ x: 32, y: 136, w: 160, selected: 1,
                       on_change: fn(self, value, i) { state.difficulty = value } },
                    [ 'Easy', 'Normal', 'Hard' ]));

A scroll container owns a column of child widgets and clips them to its box, with a wheel-and-drag scrollbar. Its children stay individually focusable, and focus follows the scroll so navigating to an off-screen child reveals it. The dropdown is the same: it opens over everything else and its options join the focus order while open.


ui.add(Gui.scroll(${ x: 220, y: 22, w: 124, h: 180 },
                  levels));   // a list of Gui.button children

Layout

Manual coordinates always work, but Gui.row and Gui.col place a list of children for you. col stacks them top to bottom from the spec's x, y; row lays them left to right. They return the same flat list, so the children stay top-level and individually focusable, and ui.add_all adds them in one call.

Two spec fields control the gaps. spacing is the gap in pixels placed between consecutive children, not before the first or after the last, so three children with spacing: 11 get two 11px gaps. It defaults to the theme's spacing (8). padding insets every child from the spec box on all sides; with a w (for col) or h (for row) set, a child that left its cross-axis size unset stretches to fill that span minus the padding, which is how the sliders and inputs below all come out the same width.


//  y=54  +------------------------+   <- padding from the box edge
//        |  [x] Fullscreen        |
//        |        gap = spacing   |
//        |  [====slider======]    |   <- stretched to w minus padding
//        |        gap = spacing   |
//        |  [ name__________ ]    |
//        +------------------------+
ui.add_all(Gui.col(${ x: 32, y: 54, w: 164, spacing: 11, padding: 6 }, [
    Gui.checkbox(${ text: 'Fullscreen' }),   // h is each child's own
    Gui.slider(${ value: 70 }),              // w left unset -> stretches to 164 - 12
    Gui.text_input(${ placeholder: 'name' }),
]));

The main-axis size comes from each child (its own w for a row, h for a column, or a min_width / min_height hint), so a column wants each child to carry a height and a row each child a width. Change spacing for one container by passing it in the spec, or for every container at once with Gui.theme(${ spacing: 4 }).

Focus and theme

Mouse and touch always work. Keyboard and gamepad focus is opt-in per container with ui.focusable(true): then Down, Tab, or d-pad down move to the next widget, Up moves to the previous, and Enter, Space, or gamepad A activate the focused one. The focus ring shows only while you navigate with the keyboard or a pad, not on mouse hover, and a motionless mouse never steals focus, so a text field you clicked into keeps the caret until you click away or tab off. Latest input wins. A widget opts out of focus with focus: false.

One default theme is shared by every container. Gui.theme(overrides) shallow-merges into it (the radius, the accent / panel / text / focus colors, and font_size), and a per-widget color always beats the theme.


Gui.theme(${ radius: 10, accent: Color.rgb(60, 70, 100) });

ui.add(Gui.button(${ text: 'Cancel', x: 64, y: 136, w: 192, h: 32,
                     color: Color.rgb(200, 55, 55) }));   // per-widget accent

UI that animates while the world is paused should run its tweens on GameTime.real (see Pausing the world). And like a Scene, the retained UI survives hot reload; rebuilding it re-binds its callbacks.

Sequencing and events

Three small modules for when things happen: run code on a delay, broadcast events between systems, and script dialogue that waits on the player. All ride the same green threads as Tween, so they compose with the clock and with pause.

Timers

Timer runs a function later, once or on repeat, with no elapsed time to track yourself.


Timer.after(2.0, fn() { spawn_boss() });        // once, in 2 seconds
beat := Timer.every(0.5, fn() { pulse() });      // every half second
// ...later:
Timer.cancel(beat);                              // stop the repeat

Both return a handle: pass it to Timer.cancel to stop a timer, or Timer.running to ask whether it is still pending. Timers ride the game clock, so they freeze while the game is paused; pass GameTime.real as a third argument to run on real time regardless.

Signals

Signal is a global event bus. One part of the game emits a named event; any number of listeners react, and neither side holds a reference to the other.


total := 0;
Signal.on('score', fn(n) { total = total + n });
// ...elsewhere, when a coin is collected:
Signal.emit('score', 10);                        // calls the listener with 10

on and once return a handle for Signal.off, and emit(name, ...args) forwards its extra arguments to every listener. This is how systems decouple: a pickup emits, the HUD and the sound both listen, and none of them import the others.

Dialogue

Say is a text box that types a line out and waits for the player to advance it. Because each say blocks until advanced, a whole conversation reads top to bottom inside a go block, like a script.


box := Say.new();

init := fn() {
    go fn() {
        box.say('A long time ago,');
        box.say('a hero set out walking.', ${ speed: 0.08 });
    };
};

update := fn(dt) { box.update(dt) };               // reads the advance key / click
draw := fn() { Gfx.print(box.text(), 20, 36) };    // you draw the box

Say is logic-only: it owns the typewriter reveal and the two-stage advance (a press mid-line reveals the whole line, a press once full moves on), and hands you box.text() to draw however your game looks, with no built-in box to fight. Pass ${ auto: 1.5 } on a line to advance it on its own, or ${ clock: cc } to run a box on a cutscene clock so it pauses with the scene.

Saving

Storage keeps the values a game needs between runs: settings, high scores, unlocked levels, save slots. It is a key/value store that survives quitting and relaunching.

Save data


Storage.set('high_score', 9000);
score := Storage.get('high_score', 0);   // 0 the first time, 9000 after

You address data by a key you choose, never a file path. That is the point: the same set and get work on the desktop and in the web export, even though the web has no filesystem. Where the bytes live (an app-data file on the desktop, localStorage on the web) is a host detail you do not need.

Storage.set(key, value)Store value under key; it must be JSON-serializable.
Storage.get(key, default)The stored value, or default (null when omitted) if it was never set.
Storage.has(key)Whether key has a stored value.
Storage.delete(key)Remove key; a no-op if it is unset.
Storage.keys()Every stored key, as an array.
Storage.clear()Remove every key in this game's store.

A value is anything JSON can represent: a number, string, boolean, null, or an array or object of those, nested as deep as you like, so a whole save slot can be one object.


Storage.set('slot1', ${
    level: 3, hp: 80,
    inventory: ['sword', 'torch', 'key'],
    pos: ${ x: 412.0, y: 96.0 },
});

The store is keyed to your game's identity, its save_id. Set it once in the window table so it stays stable across a title change or a file rename:


window := ${ title: 'Starforge', save_id: 'starforge' };

best := 0;
init := fn() { best = Storage.get('best', 0) };

Writes are immediate (each set persists before it returns, atomically on the desktop), so there is no flush to remember. A missing or corrupt save reads as empty rather than raising, so get returns your default on a first run. One quirk of the JSON round-trip: a stored number comes back as a float (a saved 5 reads back as 5.0), so round it if you need an integer. A default you pass to get is returned as-is, untouched.

Networking

For multiplayer, leaderboards, and live content, a game talks to a server over the network. Net (raw TCP / UDP / TLS sockets) and WS (cross-platform WebSocket) come straight from tigr, so you call them directly with nothing to register. WS is the one that also runs in the web export, so it is what to reach for first.

WebSocket clients

A WebSocket is a two-way message pipe to a server. Open it inside a go green thread so a slow or missing server never stalls the frame, then each frame send your state and drain whatever arrived. WS.drain(ws) hands back every message since the last call, so you never block waiting for one.


ws := null;

init := fn() {
    go fn() {                                 // connect off the frame
        ws = WS.connect('ws://127.0.0.1:9001');
    };
};

update := fn(dt) {
    if ws != null && WS.state(ws) == 'open' {
        WS.send(ws, str(floor(me.x)) + ',' + str(floor(me.y)));   // fire-and-forget
        for (m, WS.drain(ws)) { apply(m) };    // every message since last frame
    };
};

WS.state(ws) is 'open', 'connecting', or 'closed', so a client can reconnect on its own when the socket drops. A shared-avatars demo, where each window pushes its position and draws the others, pairs a client like this with a small relay server that broadcasts each message to everyone else. That server is a plain tigr program, which is the point: there is no separate host-side surface to learn.

Sockets resume on the same green-thread pump as Tween and Timer, so a coroutine waiting on the network does not stall its siblings. On native you also have raw Net (TCP / UDP / TLS, with full Windows parity); the browser cannot open raw sockets, so a game that must run on web should stay on WS. See the module reference for the full surface.

Dev tools

Two helpers for building the game rather than shipping it: a debug overlay you drive from the game, and an object pool that keeps a busy game from churning the garbage collector.

The debug overlay

Debug draws markers, watches values, and graphs frame time. You drive it from the game and gate it behind a flag, so it costs nothing when off and disappears entirely in an export.


update := fn(dt) {
    if Input.key_pressed('tab') { Debug.set_enabled(!Debug.enabled()) };
};

draw := fn() {
    Debug.cross(player.x, player.y);     // a marker in the current transform
    Debug.watch('hp', player.hp);        // a named value, shown in the panel
    Debug.overlay();                     // panel + CPU graph, in screen space
};

Debug starts on in dev and off in an export, so the calls can stay in your draw; toggle it yourself with Debug.set_enabled (and read the state with Debug.enabled()) to bind it to a key. When it is off every call is a no-op. The draw primitives (cross, circle, rect, line, vector, arrow, text) draw in whatever transform is current, so calling them under Camera.apply() marks world space and after Camera.reset() marks the screen. Debug.overlay() forces screen space and adds the value panel plus a stacked update/draw CPU-time graph against the 60fps budget line.

Object pools

A game that spawns and drops short-lived objects every frame (bullets, particles, hit sparks) makes the garbage collector work for nothing. A Pool recycles them: you give it a factory and a reset, and it hands back a used object instead of building a new one.


bullets := Pool.new(
    fn() { ${ x: 0.0, y: 0.0, vx: 0.0, vy: 0.0 } },   // make a fresh one
    fn(b) { b.vx = 0.0; b.vy = 0.0 },                  // reset on release
);

fire := fn() {
    b := bullets.acquire();      // reused if one is free, else freshly made
    b.x = player.x; b.y = player.y;
    Array.push(live, b);
};

// when a bullet dies, hand it back and swap-remove it from your own list
bullets.release(live[i]);
live[i] = live[#live - 1];
Array.pop(live);

The pool only recycles; you keep your own array of live objects and pair every release with a swap-remove, so once it is warmed up the game allocates nothing. acquire() grows the pool itself, so it never returns null unless you set a max cap (then it returns null past the cap, to drop on exhaustion). pool.with(fn(o) { ... }) acquires for the body and releases after; size, capacity, and clear introspect it.

Shipping

The last details before a build goes out: a loading screen so the first frame is not a freeze, an icon on the window and the executable, and translated text.

Loading screens

Loading images, fonts, and sounds is synchronous, so loading a lot of them in init freezes the window until it finishes. Spread the loads across frames instead, inside a go thread that yields with GameTime.wait_frame(), and draw a progress bar from the count. This is a pattern, not a module: it is about a dozen lines.


assets := [
    fn() { tex_ship = Gfx.load_image('ship.png') },
    fn() { tex_gem  = Gfx.load_image('gem.png') },
    fn() { sfx_blip = Audio.load('blip.wav') },
];
done := 0;

init := fn() {
    go fn() {
        for (load, assets) {
            try { load() } catch (e) { };       // a bad asset never wedges the bar
            done = done + 1;
            GameTime.wait_frame();              // let a frame draw between loads
        };
        start_game();
    };
};

draw := fn() {
    frac := done / #assets;                     // 0..1 for the bar
    Gfx.rect_fill(40, 150, floor(240 * frac), 12);
};

From here it is just polish: a tidy bar, a fade into the game, a retry on a failed load. There is no preload module because a wrapper would add only the progress math and the try you can already see here.

Icons

Drop one square icon.png beside your game's main file and purr uses it everywhere: the window and taskbar at runtime, the macOS app, the web favicon, the Linux launcher, and the Windows .exe icon shown before launch. Ship none and you get the purr face as a default. There is no window field to set; it is the file's presence that matters.

Localization

I18n looks a string up by key in the current locale, so the game's text lives in per-locale tables instead of in the code. A missing key returns the key itself, so a half-translated build still runs.


I18n.load('en', ${ greet: 'Hello, {name}!', lives: ${ one: '{count} life', other: '{count} lives' } });
I18n.load('fr', ${ greet: 'Bonjour, {name} !', lives: ${ one: '{count} vie',  other: '{count} vies' } });
I18n.locale('fr');

I18n.t('greet', ${ name: 'Robin' });     // "Bonjour, Robin !"
I18n.plural('lives', 3);                 // picks 'other', {count} = 3: "3 vies"

{name} placeholders interpolate from the second argument, and plural picks a CLDR category (one / other / ...) by the count, with a per-locale selector for languages whose rules are not the default. Set a fallback locale so a key missing in the current one falls back before returning the key. The scope is Latin and extended-Latin text; complex scripts (CJK, right-to-left, shaping) are a separate font effort. Tables are plain objects, so you can keep each language in its own JSON file and load it through Assets.

Where next

This guide covers the parts you reach for first. For every function, with full signatures and the corners this skips, browse the module reference. To see programs running, the homepage has live examples you can click into.

Module reference → See the examples