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.
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 help | The 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.
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.
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.
| Field | Default | What it sets |
|---|---|---|
| title | 'purr' | The text in the title bar. |
| width, height | 480, 270 | The starting window size in pixels. |
| resizable | true | Whether the player can resize the window. |
| fullscreen | false | Open fullscreen. |
| vsync | true | Sync drawing to the display refresh. |
| high_dpi | false | Render at the display's true pixel density. |
| msaa | 4 | Antialiasing sample count. Set 1 for hard pixel-art edges. |
| recording | true | The F9 GIF-recording hotkey. Set false to reclaim F9 for the game. |
| screenshot | true | The 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.
| /reload | Restart 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. |
| /clear | Clear the console scrollback. |
| /help | List 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.
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, size) and make it current with Gfx.font(handle).
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);
};
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).
| Actions | Raw keys | True 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.
playreturns 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 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
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');
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 wait as one more step.
Math and geometry
Four 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
};
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, withtrunning from 0 (alla) to 1 (allb).
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);
};