f Festina / Docs

API Reference

The Festina language & standard library

As implemented today. For exactly what's implemented vs. not (and known caveats), see tests/CONTRACT.md; for the full target-language spec this compiler is built against, see claude.md.

CLI #

Five subcommands (festina/cli.py), not a single bare festina file.f — that would leave festina run (which executes the compiled result) ambiguous with festina compile (which never does) without inventing a flag to distinguish them.

festina compile entry.f -o outCompile to a native executable at out (default: entry's own filename without .f). --emit-llvm prints LLVM IR to stdout instead of linking. --cc picks the C compiler/linker (default: whichever of clang/gcc/cc is found first). --target=wasm32-wasi cross-compiles to a standalone .wasm binary instead — see wasm.md (graphics/audio aren't available under WASI).
festina run entry.fCompile to a throwaway temp executable and run it immediately — stdin/stdout/stderr inherited directly (not captured), so an interactive program (graphics/audio/timers) behaves exactly like a normal compile-then-run. Exits with the compiled program's own exit code, so festina run x.f && ... composes the same way go run/cargo run do. The temp binary is always cleaned up afterward. --target=wasm32-wasi runs the compiled .wasm through Node's built-in WASI support instead of executing it directly.
festina doctorChecks every dependency the compiler itself needs (a C compiler, pkg-config, sqlite3/cairo-xlib/alsa dev headers, libLLVM) and reports what's missing and how to install it — the same install hints a real compile failure would give, just checked proactively instead of only on failure. Also reports whether festina itself is resolvable on PATH. Exits 0 if every required dependency is present — graphics/audio are optional.
festina doctor --fixSame report, then actually fixes what it found: installs missing dependencies via the detected package manager (apt on Linux, Homebrew on macOS, MSYS2's pacman on Windows), and adds festina to PATH if it isn't resolving. Prints the exact command/change first and asks for confirmation (--yes/-y skips that); refuses to guess for any other package manager or overwrite something unrelated already on disk.
festina updatePulls the latest source into this installation's own git checkout and fast-forwards to it (git fetch + git merge --ff-only) — there's no separate release pipeline or package to fetch, the running festina is this checkout, so updating it is exactly this. Refuses, with a clear message and no changes made, when the working tree has uncommitted changes, when HEAD is detached, or when local history has genuinely diverged from origin — never force-resets over local work. Not available for a packaged (PyInstaller) binary, which has no source tree of its own to pull into.
festina helpPrints this same command list.
$ bin/festina compile program.f -o program   # compile
$ bin/festina compile program.f --emit-llvm  # print LLVM IR instead of linking
$ ./program                                  # the result needs neither
                                              # Python nor festina/ to run
$ bin/festina run program.f                  # or just run it directly
$ bin/festina doctor                         # check dependencies

Compilation pipeline #

Festina source (.f)
   -> AST (festina.parser)
   -> Semantic analysis (festina.semantic)
   -> LLVM IR (festina.codegen)
   -> Object file (festina.llvm_backend, via libLLVM -- or clang's IR
      frontend as a fallback)
   -> Link against the runtime (runtime/festina_runtime.c and,
      conditionally, _graphics.c/_audio.c -- see "Binary size" below)
   -> Native executable

A program needs no main() — top-level statements in the entry file run in order, after every import is resolved and every declared table's schema is synced.

Binary size #

A compiled program only links what it actually uses: the graphics (Cairo/X11) and audio (ALSA) runtime object files are only passed to the linker when the program calls something from them (see security.md). libsqlite3 is always available (statically linked when possible), since automatic database support (table, sqlite()) is always on.

Imports #

import database.f
import graphics.f

Resolved recursively before compilation into one merged compilation unit; a file is never imported more than once even if multiple files depend on it; errors still point at the file a statement actually came from.

Types #

int       -- 64-bit signed integer
float     -- 64-bit floating point (IEEE 754 double)
bool      -- true / false, no truthy/falsy coercion from anything else
text      -- UTF-8 string
blob      -- a file's bytes, loaded from a path (blob save = 'slot1.dat');
             see the Files section below
arr[T]    -- homogeneous array of any of the above, a struct, or a
             declared table's row type
map[T]    -- text-keyed map of any of the above except arr[T]/map[T]
             itself (see the Maps section below)
struct    -- user-declared record type
table     -- a struct that's also backed by a SQLite table
img       -- an image, declared from a path (img hero = 'hero.png')
aud       -- an audio clip, declared from a path (aud hit = 'hit.wav')
regex     -- a compiled pattern from a /pattern/flags literal or regex() (opaque handle)
color     -- a canvas color, declared from a literal (color red = 'red')
font      -- a canvas font, declared from a literal (font body = '13px arial')

color and font are resolved by the compiler at the declaration that names them — see Drawing style. A color is a packed integer and a font is a pointer to a constant in the binary, so neither is reference-counted and neither costs anything at runtime.

Every type may hold null (bool x = null included). Comparing a null int/bool against the null literal with ==/!= works as expected, but a null float never compares equal to null via ==, even to itself — it's a real NaN under the hood, and IEEE-754 NaN comparisons are always false.

int and float mix freely in any binary operator — arithmetic, comparison, or equality — with the int side implicitly promoted to float, as though .toFloat() had been written on it:

int a = 5
float b = 2.5
float c = a + b          // 7.5 -- a is promoted to float automatically
bool less = a < b         // false

/ (division) always returns float, even when both operands are int — the one operator that promotes unconditionally, not just when mixed:

int x = 10
int y = 3
float z = x / y           // 3.33333 -- not 3

Every other arithmetic operator (+, -, *, %) only promotes when the two operands actually differ — int + int still returns int. Declaring a variable as int from a genuinely float-typed expression (mixed, or from /) is still an ordinary type mismatch — the promotion changes what an expression evaluates to, not what a declared type accepts:

int bad = a + b            // compile error -- a + b is float

The only way back from a float to an int is still the rounding four (Math.floor/ceil/round/trunc) — int.toFloat() is still the one-directional int → float conversion, mostly redundant with the implicit promotion above but still useful for forcing a result to float explicitly, e.g. inside a template literal.

// float -> int (the rounding four)
Math.floor(x)   Math.ceil(x)   Math.round(x)   Math.trunc(x)

// float -> float
Math.sqrt(x)    Math.abs(x)    Math.exp(x)
Math.sin(x)     Math.cos(x)    Math.tan(x)
Math.asin(x)    Math.acos(x)   Math.atan(x)
Math.log(x)     Math.log2(x)   Math.log10(x)

// (float, float) -> float
Math.pow(a, b)  Math.min(a, b)  Math.max(a, b)  Math.atan2(y, x)

// (int, int) -> int
Math.floorDiv(a, b)

// no arguments -> float
Math.random()   // in [0, 1)

// constants
Math.PI   Math.E

Only the rounding four (and Math.floorDiv) return int; everything else returns float, because "which integer" and "which real number" are different questions — Math.sqrt(2.0) is a float.

Math.floorDiv(a, b) rounds toward negative infinity, unlike /'s own truncate-toward-zero — the two only disagree when the operands have different signs and the division isn't exact:

log(Math.floorDiv(-7, 2))   // -4, not -3 -- -7 / 2 truncates to -3.5 -> -3
log(7 / 2)                  // 3.5 (float) -- ordinary division still promotes

Grid/tile code is the common case: Math.floorDiv(worldX, tileSize) gives the containing tile's index correctly for negative coordinates too, where Math.floor(worldX / tileSize) would otherwise need the extra .toFloat()/rounding step spelled out by hand. Like //%, dividing by zero returns null rather than crashing.

Math.random() is seeded once from the clock and is suitable for gameplay and sampling — not for anything security-related. It returns a value in [0, 1), so Math.floor(Math.random() * n) is always a valid index.

Division/modulo by zero return null rather than crashing:

float divided = 10 / 0   // null -- / always returns float
int remainder = 10 % 0    // null -- % still returns int for two ints

Testing for that null works for the int result but not the float one: a null float is a real NaN, and IEEE-754 says every comparison against a NaN is false, so divided == null and divided != null are both false. remainder == null is true as you would expect. See the null representations above.

int/float/bool also each have .toText(), returning the same text template interpolation already produces for that value implicitly — useful when that text is needed outside of a template:

int count = 5
log(count.toText())   // "5" -- identical to log(`${count}`)

Variables, constants, functions #

int count = 10
const int max = 100      // reassigning a const is a compile error
text message = 'Hello'

int func add(a:int, b:int) {
    return a + b
}

void func log_it() {
    log('called')
}

No var/let — every declaration states its type.

Functions are first-class values #

A bare function name (not called) is a value of type func[paramTypes]:returnType, usable anywhere a value can go: a variable, a function argument, a struct field, an array element, a map value. Calling through one of those — not just the function's own original name — works exactly like calling the function directly:

void func greet(name:text) { log(name) }

func[text]:void cb = greet
cb('world')                              // "world"

void func apply(fn:func[text]:void, arg:text) { fn(arg) }
apply(greet, 'hi')                       // "hi"

struct Handler { onEvent:func[text]:void }
Handler h
h.onEvent = greet
h.onEvent('yo')                          // "yo"

int func inc(x:int) { return x + 1 }
arr[func[int]:int] transforms = [inc]
log(transforms[0](5))                    // 6

func[]:void is a zero-argument, void-returning function type; null is a valid value of any func type too. There are no closures — a function's own type never captures anything from where it's declared, so a func[...]:... value is always just a plain reference to one of this program's own function declarations, nothing more. setTimeout/setInterval's own callback argument is unaffected by any of this: it's still the bare name of a zero-parameter, void-returning function specifically, not an arbitrary func[...]:...-typed expression.

Arrow functions #

returnType (params) => expr is an anonymous function value, compiling to an ordinary function with a compiler-generated name; the arrow expression itself evaluates to a func[...]:... reference to it, usable anywhere the plain-function examples above are:

func[text]:void cb = void (arg:text) => log(arg)
cb('world')                                        // "world"

func[int]:int sq = int (x:int) => x * x
log(sq(7))                                         // 49

void func apply(fn:func[text]:void, arg:text) { fn(arg) }
apply(void (arg:text) => log(arg), 'hi')           // "hi"

A void-returning arrow function's body is a plain expression, run for its side effects with its own value discarded (there's no return inside a void function's body to write, arrow or not); a non-void one's body is its return value, no return keyword needed. Arrow functions have no closures either, for the identical reason plain functions don't: a variable reached from inside the arrow body compiles correctly only if it's a top-level global (itself visible to every function already) — a genuinely local variable from wherever the arrow expression is written is not reachable from inside it.

Functions are hoisted #

Every function's name and signature exists everywhere in the program, so calling one from above its own declaration (including mutual recursion between two functions, each necessarily calling the other before its own declaration) is not an error:

log(greet('world'))    // fine -- greet is declared below

text func greet(name:text) {
    return 'Hello, ' + name
}

A function can also be declared nested inside an if/while/for block, or inside another function — wherever it's written, it's still one single, ordinary, globally-callable function (there's no lexical scoping/closures for functions to begin with), so a call to it works regardless of where the call site sits relative to that nested declaration.

Control flow #

if condition {
    ...
} else {
    ...
}

bool result = condition ? 'yes' : 'no'   // ternary
bool both = a && b                        // short-circuit
bool either = a || b                      // short-circuit

for int i = 0, i < 10, i++ {
    if i == 5 { break }
    if i % 2 == 0 { continue }
    log(i)
}

while condition {
    ...
}

Conditions must be bool — no implicit truthiness from int/text/etc. break exits the nearest enclosing for/while loop immediately; continue skips to that loop's next iteration (a for loop's update expression still runs first). Both are a compile error outside any loop, and both only ever affect the nearest enclosing loop — no labeled break/continue targeting an outer one. Postfix ++/-- work on any mutable int variable.

Strings #

text greeting = `Hello, ${name}!`     // template literals
text a = 'room 42'.replace('room', 'suite')
text b = 'a1b2c3'.replace(/[0-9]/g, '-')   // 'g' = every match
bool matched = /[0-9]+/.test('room 42')
text found = 'room 42'.match(/[0-9]+/)   // null if no match

arr[text] words = sentence.split(' ')    // or a regex: .split(/\s+/g)
sentence = words.join('\t')              // join works on text/int/float/bool arrays

Empty pieces between adjacent separators are kept — 'a,,b'.split(',') has three pieces, a separator at the edge yields an edge empty, an empty-match regex splits between characters, and an empty text separator splits per UTF-8 code point. join renders a null element as an empty string: [1, null, 3].join('-') is '1--3'.

Building a string piece by piece #

This is O(n). Every text binding owns its buffer outright, so an assignment that appends to itself —

text out = ''
for int i = 0, i < n, i++ {
    out = `${out}${i},`          // or: out = out + row.name + '\n'
}

— is compiled as a real append: out's own buffer is grown in place (geometrically, with a length the compiler tracks), not copied into a fresh one on every step. The shape is exactly x = `${x}…` or x = x + … where x is a plain text variable, parameter or top-level global, x appears once at the front, and every further piece is a literal, another variable, or a plain field read (anything that could call a function, or read x again, takes the ordinary copying path). Nothing about it is visible except the time: 15,000 appends move a few kilobytes instead of ~112 MB.

Parsing an int #

int n = '42'.toInt()          // 42
int m = '  -17abc'.toInt()    // -17 -- leading whitespace, sign, trailing garbage all ok
int bad = 'nope'.toInt()      // null -- not a compile error, not a crash
if bad == null {
    fail('not a number')
}

toInt() skips leading whitespace, reads an optional +/-, then digits until the first non-digit (or the end of the text) — whatever comes after the digits is ignored, not an error. Returns null if no digits were found at all. A literal receiver ('42'.toInt()) is computed entirely at compile time; nothing is emitted to parse it at runtime.

Indexing a character out #

text s = 'hello'
log(s[0])              // h
log(s[10])              // null -- out of range, not a crash
log(s[-1])              // null -- negative, not a crash

s[i] reads the i-th UTF-8 code point (not byte — a multi-byte character like é is one index, matching .length's own code-point count), as a fresh text. Unlike array indexing, this is always bounds-checked: an out-of-range or negative index answers null rather than reading past the buffer. Read-only — s[0] = 'x' is a compile-time error, the same way environment.NAME = ... is.

Length #

log('hello'.length)   // 5
log('café'.length)    // 4 -- code points, not bytes ('é' is 2 bytes)
log(''.length)        // 0

text.length is the number of UTF-8 code points — the same unit s[i]/charCodeAt/split('') already use, not a byte count. Because UTF-8 is variable-width, this is a real scan, not a stored count — unlike blob.length, which counts bytes exactly and is O(1). Read-only, like arr[T].length.

charCodeAt() and toChar() #

int fortyTwo = 42
text char = fortyTwo.toChar()          // '*'
int numberAgain = char.charCodeAt(0)   // 42

log('a'.charCodeAt(0))    // 97
log(42.toChar())          // '*'
log('café'.charCodeAt(3)) // 233 -- the 'é', by code point, not byte
log(233.toChar())         // 'é'

text.charCodeAt(i) reads the Unicode scalar value of the i-th code point (not byte, and not a UTF-16 code unit the way JavaScript's own charCodeAt sometimes is — Festina's text indexing is code-point-based everywhere, and this matches s[i]/.length). int.toChar() is the inverse: it UTF-8 encodes a code point into a one-character text. Both follow the same "test, don't fail" rule as s[i]: an out-of-range or negative index into charCodeAt answers null, and a code point toChar() can't represent — negative, above 0x10FFFF, or inside the UTF-16 surrogate range 0xD800–0xDFFF — answers null rather than crashing.

ascii — one byte per character #

text is UTF-8, so a character can be one to four bytes. That makes text.length a real scan and s[i] a walk from the start of the string, and it is the right trade for text that has to hold any language. But it is the wrong trade for a scanner — a lexer reads every character by index, and a walk per read is quadratic over the whole input.

ascii is the other trade. One byte per character means the character count is the byte count, so it lives in the value's own header and both .length and s[i] are O(1) reads:

ascii src = 'int x = 1'
log(src.length)          // 9   -- a stored count, not a scan
log(src[4])              // 'x' -- a byte offset, not a walk
log(src.charCodeAt(4))   // 120
log(src.slice(0, 3))     // 'int'
log(src == 'int x = 1')  // true

Both types coexist; neither replaces the other. Use text for anything a person types or reads, and ascii where the input really is one byte per character and you index it heavily — source code, protocol headers, CSV fields.

Converting #

A quoted literal is a text literal. Assigning one to an ascii converts it at compile time, so a literal that isn't ASCII fails to build rather than deferring to a runtime null:

ascii ok = 'let'          // fine
ascii bad = 'café'        // compile error: 'é' is not an ascii character

At runtime the conversion has to be checked, so it answers null for anything not representable one byte per character — the same "test, don't fail" rule s[i] and toInt() already follow:

blob f = 'input.txt'
ascii scan = f.toText().toAscii()   // null if the file isn't ascii
if scan == null { fail('expected ascii input') }

text back = scan.toText()        // always works, always a copy

Cost #

.length, s[i] and charCodeAt(i) are O(1). Indexing allocates nothing at all: a one-character ascii comes from a table of 128 immortal single-character values rather than a fresh buffer, so a character-by-character scan does no allocation whatsoever.

.length and charCodeAt(i) cost no function call either — both compile to loads off the value's own header, inline in the loop that uses them, so a scan loop's body contains no call at all.

slice() and + copy, because an ascii owns its bytes.

The difference this makes to a scanner is not small. Counting identifiers character by character over the same input:

inputtextascii
10.4 KB50 ms—
20.8 KB201 ms—
41.6 KB800 ms—
4.16 MB—21.5 ms

text quadruples when the input doubles (a walk per index, over every index); ascii doubles. At 41.6 KB text needs 800 ms; ascii scans 4.16 MB — a hundred times more input — in 21.5 ms.

Against other languages on the same scan, ascii is competitive rather than merely better than text: see char_scan, where Festina finishes ahead of equivalent Rust and Go loops indexing raw bytes.

An ascii is reference counted, so ascii b = a shares one buffer rather than copying it, and b is not a snapshot: see T? for what ascii? means.

Logging and rendering #

log() and ${} interpolation accept any value that has a text form — a non-text value compiles as its own .toText():

  • int / float / bool — the number, true/false, or null.
  • structs, table rows, arrays, maps — JSON-like.
struct P { id:int  name:text  xs:arr[int] }
P p
p.id = 7
p.name = 'x'
p.xs.push(1)
log(p)                     // {"id":7,"name":"x","xs":[1]}
log(`state: ${p.xs}`)      // state: [1]
text json = p.toText()     // the explicit spelling of the same rendering

Text is escaped JSON-style; a null text/element renders as null; a table-row column the query never selected is omitted from the object, the same way an absent key would be; a database NULL renders as null. An opaque handle inside a container (a blob/img/aud/regex field) renders as a placeholder like "<blob>", or null when unset. A cyclic value truncates at depth 32 instead of crashing. An unassigned scalar field renders its zero value (per the zero-value rule); a field explicitly assigned null renders null.

blob — the contents, via its own .toText(): a blob is very often a text file. (A binary blob renders its bytes up to the first NUL — the same thing its explicit .toText() does.) A blob field inside a rendered container still shows as the "<blob>" placeholder, since embedding a whole file mid-JSON would drown the structure the rendering exists to show.
blob f = 'notes.txt'
log(f)                     // the contents
log(`config: ${f}`)        // interpolates the contents

img, aud — a compile error: neither has a text form, and silently printing a placeholder would hide a mistake the type system can catch.

Structs #

struct User {
    id:int
    name:text
    active:bool
}

User user
user.id = 1
user.name = 'Patrick'

Structs are native in-memory records — declaration, field read/write, and passing to/returning from functions by value.

An unassigned field reads as its zero value, and that includes a field whose own type is a struct, arr[T], or map[T] — reaching through one before anything was assigned to it gives you a real, empty value rather than an error:

struct Inner { n:int }
struct Bag   { inner:Inner  xs:arr[int]  m:map[int] }

Bag b
log(b.inner.n)      // 0
log(b.xs.length)    // 0
b.xs.push(1)        // works -- the array is created on first reach
b.m['k'] = 9

The value is created once, on first reach, and stays — the read above and the push below it are talking about the same array.

A struct can name itself #

A field may have the type of the struct it is declared in, or the type of a struct declared further down the file. Declaration order does not matter:

struct Node {
    n:int
    next:Node
}

Node head
head.n = 1
head.next.n = 2       // auto-vivified, same as any other struct field
head.next.next.n = 3

Node cursor = head
for int i = 0, i < 3, i++ {
    log(cursor.n)
    cursor = cursor.next
}

Linked lists, trees and parent pointers all work, and are reclaimed automatically like any other struct — including cycles. Reference counting alone can never free a value that points back at itself (the loop keeps its own count above zero forever), so for types that can form a cycle the compiler adds a cycle detector: when such a value is released but still referenced, the runtime checks whether what remains is only the cycle holding itself, and frees it if so.

Node a
a.n = 7
a.next = a      // reclaimed when `a` goes away, cycle and all
A cycle something still points at is never touched — parent pointers, rings and doubly-linked structures stay valid for exactly as long as anything outside them can reach them. Cycles through containers (kids:arr[Tree] with a parent:Tree back-pointer, a map of peers) collect the same way. Programs whose types cannot form a cycle carry none of this machinery.

Memory for structs, arrays, and maps is managed automatically — no manual allocation or freeing, anywhere:

Non-escaping locals, stack-allocated. A local struct/arr[T]/map[T] declared in a function, event handler, if branch, while body, or for body, and never returned or stored anywhere longer-lived, is reclaimed automatically as soon as control leaves the block it was declared in — for a value declared inside a loop body, that means every iteration; break/continue reclaim it too. A non-escaping struct is a real stack allocation, not a heap allocation freed afterward. Passing one to another function doesn't force it to escape either — if that function's own body never lets the value outlive the call, the original is still reclaimed the same way.
Escaping values, reference counted. Anything that does escape — returned, stored in a global or a struct field/array element, passed on and actually retained — is reference counted instead: freed once nothing references it anymore. This covers a struct-typed global on every reassignment, an escaping local, a value handed back through return, a call result discarded outright, a struct's own struct-typed fields, and an arr[T]/map[T]'s own elements/values — however many levels deep a program nests them. Two variables made to alias each other (map[T] b = a) correctly share one underlying value rather than silently diverging.
text, reclaimed a different way. Rather than being reference counted, every text-typed binding — local, global, struct field, array element, map value, parameter — always holds its own private copy of the string, made automatically wherever one is needed. So unlike arr[T]/map[T], two text variables never come to share one underlying buffer: text b = a gives b its own copy. That's what lets a text value be freed on every reassignment and at every scope exit unconditionally, with no escape analysis involved — a loop that rebuilds a string each iteration (`${s}x`) frees the previous buffer every time instead of accumulating them.

Query results are reclaimed too: the rows an arr[Table] holds, and each row's own text columns, are freed when that array is — so a program that queries repeatedly stays bounded. A row is reference counted like a struct, so a single row read out of one (People p = rows[0]) holds its own reference and stays valid even if the array goes away first. Rows alias rather than copy: writing p.name = 'x' is visible through every binding of that row, including the array's own element.

img, aud and regex handles are reference counted exactly like structs: every binding — aliased, escaping, or a /pattern/ literal's — is released when it goes away, and the surface, decoded clip, or compiled automaton is destroyed when the last reference drops. img b = a shares one handle; free a afterwards is a decrement and b stays usable. A /pattern/ literal's process-lifetime compilation is immortal, so every release that reaches it is a safe no-op. The one thing not reclaimed is text globals at process exit, where the operating system reclaims everything anyway.

Enums #

struct Circle { radius:int }
struct Square { area:int }

enum Shape = Circle, Square

int func extractShapeMetric(shape:Shape) {
    if typeof shape == 'Circle' {
        return shape.radius
    } else {
        return shape.area
    }
}

Circle c
c.radius = 5
log(extractShapeMetric(c))   // 5

enum Name = Member1, Member2, ... declares a tagged union: a Shape-typed value can hold a Circle or a Square, and the language remembers which one it actually is at runtime. A member may be any type — a struct, a primitive, an arr[T]/map[T], or any other built-in type — not only structs.

Assigning a member value into an enum-typed slot (a variable, a function parameter/return, a struct field, an array/map element) is an automatic, one-directional coercion — the reverse never happens implicitly:

Shape shape = c        // Circle -> Shape, fine
Circle back = shape     // compile error -- Shape is not a Circle

typeof #

typeof <expr> reads a value's own concrete runtime type as text. For anything not enum-typed, the answer is always the value's own static type — typeof 5 is 'int', typeof myUser is 'User' — since nothing outside an enum can hold more than one type at runtime. On an enum-typed value, typeof returns whichever member is actually stored — never the enum's own name, so typeof shape above is 'Circle' or 'Square', never 'Shape':

log(typeof shape)             // 'Circle'
log(typeof shape == 'Circle') // true

Field access #

shape.radius reads straight through to the field of whichever member struct shape currently holds — but only when every member of the enum is a struct, and no two members declare a field with the same name (rejected at the enum declaration itself, since shape.radius would otherwise be ambiguous). A member with no field of that name at all is also rejected at compile time. Field access on a mixed enum (any non-struct member) is a compile error — there is no single field layout to read through when the value might currently be an int.

Reading a field the currently-held variant doesn't actually have — a missing typeof guard, or a wrong one — fails loudly at runtime (fail) rather than silently reading whatever bytes happen to sit at that offset in a different struct's layout. Reaching either typeof or field access on an enum-typed value that was declared but never assigned (it reads null, like any other reference-typed value with no auto-vivify) fails the same loud way instead of crashing.

struct Circle { radius:int }
struct Square { area:int }
enum Shape = Circle, Square

Square s
s.area = 42
Shape shape = s
log(shape.radius)   // fail: field 'radius' is only valid when this Shape value is a Circle

Guard with typeof before reading a field whose presence depends on which variant you actually have:

if typeof shape == 'Circle' {
    log(shape.radius)
} else {
    log(shape.area)
}

match #

The if typeof shape == '...' chain above written as a statement of its own, exhaustiveness-checked against every member of the enum:

int func extractShapeMetric(shape:Shape) {
    int result = 0
    match shape {
        'Circle' { result = shape.radius }
        'Square' { result = shape.area }
    }
    return result
}

Each arm is a quoted tag — the exact same text typeof itself returns — followed by a { } block; no case/:. default { } covers everything the written arms don't:

match shape {
    'Circle' { log('a circle') }
    default { log('something else') }
}

Leaving a member uncovered with no default is a compile error naming the missing one, not a silent gap:

match shape {
    'Circle' { log('a circle') }
}
// error: match on 'Shape' does not cover 'Square' -- add a case or a default

An arm tag that isn't a real member, or a tag repeated across two arms, is also a compile error — a typo or a copy-paste duplicate is caught before it can silently do nothing. match works on any expression, not only an enum — match n { 'int' { ... } } — with the same exhaustiveness rule applied to its one static type.

match's subject must be a plain variable or field access (shape, w.shape) — not a call, a computed index, or any other expression that could run code or allocate:

match nextShape() { ... }
// error: match's subject must be a plain variable or field access --
// bind a call result to a name first

Bind it to a name first (Shape s = nextShape(); match s { ... }), the same idiom typeof's own examples already use. This is what makes match free: it desugars entirely, at compile time, into the identical typeof/if/else if chain shown above — the subject is evaluated exactly once regardless of how many arms exist, and the compiled program has no match-specific code path to pay for at all.

Representation and cost #

A pure-struct enum (every member a struct) is zero-overhead: a Shape-typed value is whichever member struct's own pointer it currently holds, self-tagged in that struct's own heap header — no extra allocation, no wrapping. A mixed enum (any non-struct member) gets a small heap-boxed {tag, value} wrapper instead, refcounted the same way a struct is. Either way, reassigning or letting an enum-typed value go out of scope releases whatever it held exactly like any other refcounted value.

Arrays #

arr[int] numbers = [1, 2, 3]
arr[arr[int]] matrix
log(numbers.length)
numbers[0] = 10

Memory is reclaimed automatically — see Structs above for the full picture (non-escaping locals reclaimed at scope-exit, escaping values reference counted).

arr[img]/arr[blob]/arr[aud] load each element from a path, the array-typed counterpart of img sprite = 'sprite.png':

arr[img] brushes = ['./brush1.png', './brush2.png']
arr[blob] saves = ['slot1.dat', 'slot2.dat']

An element may also already be a value of the array's own media type (reusing an existing img/blob/aud, aliased rather than reloaded) — mixing the two in one literal is fine.

Indexing is not bounds-checked #

numbers[i] is a raw memory access, and keeping i in range is yours to guarantee. This is the one place Festina hands you a loaded gun, and it is deliberate: an index is checked in the hot path of every loop a game writes, and the check would cost more than the language is willing to spend. Nothing about it is soft.
  • Reading past the end returns whatever bytes follow the array. Not null, not a zero, not an error — arbitrary heap contents, different on each run.
  • Writing past the end corrupts the heap. Confirmed under AddressSanitizer as a genuine heap-buffer-overflow. It may crash immediately, or corrupt an unrelated value and crash somewhere else much later, or appear to work.
  • A negative index is the same, backwards.
  • .length is always right; nothing else is checked against it.

So guard the index yourself:

if i >= 0 && i < xs.length {
    log(xs[i])
}

This applies only to arr[T] indexing. A missing map[T] key answers null (see Maps); pop()/shift() on an empty array answer null, and splice() clamps its own range (see Growing arrays below). Indexing is the only unchecked operation in the language.

amor — amortized-growth arrays #

amor arr[int] scores = []
const amor arr[text] tags = ['a', 'b']   // composes with const

int i = 0
while i < 10000 {
    scores.push(i)
    i = i + 1
}

amor arr[T] — an "amortized array" — is arr[T] with a different internal growth strategy: doubling capacity as needed instead of growing by exactly one element per push, so a long run of pushes costs O(log n) reallocations instead of O(n). Same literal syntax, same indexed get/set, same methods (push()/pop()/shift()/unshift()/splice()), and the same .toText()/JSON rendering as plain arr[T] — amor only changes how the value grows internally, never what it does or looks like from the outside.

Requires an initializer — amor arr[int] xs with no = ... is a compile error — unlike a plain arr[T], which can start implicitly empty, an amortized array's own declaration always needs a real value to store. Composes with const (const amor arr[T] xs = ...); as a struct field (xs:amor arr[int]), no initializer is needed or possible, the same as any other struct field — it starts empty the first time the field is actually touched.

An amor arr[T] and the plain arr[T] of the same element type are two genuinely different types, the same way int and float are — assigning one to the other, or passing one where the other is expected, is a compile error. Convert by copying element-by-element (a loop, or arr[T] plain = amorXs.splice(0, amorXs.length), which empties the amortized array into a fresh plain one) if you need to cross that boundary.

Growing arrays #

arr[int] xs = [1, 2, 3]

xs.push(4)          // -> new length
xs.pop()            // -> last element, removed
xs.shift()          // -> first element, removed
xs.unshift(0)       // -> new length
arr[int] cut = xs.splice(1, 2)   // remove 2 from index 1, return them
xs.splice(1, 0, [8, 9])           // insert [8, 9] at index 1, remove nothing
xs.indexOf(3)       // -> first index holding 3, or -1

splice clamps rather than failing — a negative start counts back from the end, and an oversized range clamps to what's actually there, so splice(i, 1) at a boundary is a no-op. An optional third argument, splice(start, count, insertArr), inserts — the items to insert are one explicit arr[T] rather than a variadic list (Festina has no variadic calls); either way only the removed elements come back, never the inserted ones.

push()/unshift()/pop()/shift()/splice() each resize the backing buffer to exactly the new length internally, not amortized — see amor — amortized-growth arrays above if that matters for a specific array (a long run of pushes in particular).

pop()/shift() on an empty array return null — not zero, so an empty pop is distinguishable from popping a real 0:

arr[int] empty = []
log(empty.pop() == null)     // true

indexOf() answers -1 when the value isn't present, rather than null — an index is the kind of thing you compare or feed straight to splice, and both read naturally against -1:

if queue.indexOf(target) >= 0 { ... }
queue.splice(queue.indexOf(target), 1)   // remove by value

What "the same value" means depends on the element type:

  • int, float, bool — by value.
  • text — by content, so a needle built at runtime finds a match: names.indexOf('gr' + 'ace') is 1 for ['ada', 'grace']. (Identity would be useless here: text is copied on binding, so two equal strings are almost always two different buffers.)
  • struct, arr, map — by identity. Two separately-declared structs with identical fields are two different values; only the one actually in the array is found.

Elements are owned the same way any other binding owns them: pushing a text copies it, so the array and the variable don't share a buffer. Removing transfers ownership to whoever receives it. indexOf() takes no ownership at all — an index isn't a reference.

Sorting: sort(cmpFn) #

int func byAsc(a:int, b:int) { return a - b }

arr[int] xs = [5, 3, 8, 1]
xs.sort(byAsc)         // in place -- xs is now [1,3,5,8]

sort() takes a comparator, cmpFn:func[T,T]:int, and sorts in place — JavaScript's/C qsort()'s convention: return negative if the first argument belongs before the second, positive if after, 0 if they're equal. Sorting is stable — two elements the comparator calls equal keep their original relative order, so sorting a list twice by two different keys ("sort by name, then re-sort by score" to get "score descending, ties broken by name") behaves the way it reads.

The comparator can be any func[T,T]:int-typed expression, not just the bare name of a declared function — a variable holding a function value works too, the same first-class-function rule every other callback (.callback()) already follows.

struct Enemy { name:text y:int }

int func byDepth(a:Enemy, b:Enemy) { return a.y - b.y }

arr[Enemy] enemies = [...]
enemies.sort(byDepth)   // back-to-front draw order by Y position

Maps #

text npc2Id = 'npc2'
map[int] npcHealths = {'npc1': 10, npc2Id: 15}   // keys are always text
map[text] npcNames = {'npc1': 'jim', npc2Id: 'john'}

npcHealths['npc1']          // -> 10
npcHealths[npc2Id]          // -> 15 -- a key can be any text expression
npcHealths['missing']       // -> null -- a missing key, not an error

npcHealths['npc1'] = 30     // updates an existing key
npcHealths['npc3'] = 5      // adds a new one

void func logHealth(h:int, key:text) {
    log(`${key} ${h.toText()}`)
}
npcHealths.forEach(logHealth)   // (value, key) -- visit order is unspecified

arr[text] ids = npcHealths.keys()     // a plain snapshot, walkable with a for loop
arr[int] hps = npcHealths.values()    // no callback, no extra globals needed

An unquoted identifier key (npc2Id above) is a reference to that variable's own text value, not bareword-as-string-name shorthand the way some object-literal syntaxes allow. map[T]'s T may be any type except arr[...]/map[...] itself (a map value is stored in one fixed-size slot, which those two don't fit in). .forEach()'s callback must be an already-declared function taking exactly (value, key:text) and returning nothing, the same "bare name of a declared function" restriction setTimeout's callback has — since it takes no closures, collecting matching entries into your own accumulator otherwise means promoting that accumulator to a global just so the callback can reach it. .keys()/.values() sidestep that for the common case: both take no arguments and return an ordinary, independent snapshot array (arr[text]/arr[T]) taken once, at the call — a later change to the map (delete, a new key, free) never retroactively changes what was already returned. Order matches .forEach()'s own: unspecified, a function of each key's hash rather than insertion order. A genuine hash table internally — open addressing (linear probing), FNV-1a hashing, tombstone deletion, doubling capacity whenever the table crosses 75% load — average O(1) get/set/delete rather than a scan over every entry, growing geometrically the same way amortized arrays do, without needing a separate opt-in type for it.

map[T] has no amor variant — a plain map[T] already grows geometrically as an intrinsic part of being a hash table, so there is nothing left for amor map[T] to opt into; the amor keyword only ever applies to arr[T] (see Arrays above).

Built-in SQLite #

table People {
    id:int
    name:text
}

arr[People] people = sqlite('SELECT * FROM People')
sqlite('INSERT INTO People (id, name) VALUES (?, ?)', [1, 'Patrick'])

Every table declaration is synced against festina.sqlite at startup — created if missing, columns added/dropped/retyped (existing data preserved via a temp-table rebuild) to match the declaration exactly. sqlite()'s optional second argument (bound parameters) must be a literal array expression, not an arbitrary arr[T] value. Query result columns map onto a declared table's fields by name — see Partial queries and undefined() below.

Storing images, audio and files #

A column may be an img, an aud or a blob. SQLite stores each as a BLOB:

table Music {
    name:text
    file:aud
}

aud track = 'adventure.mp3'
sqlite('INSERT INTO Music (name, file) VALUES (?, ?)', ['theme', track])

arr[Music] rows = sqlite('SELECT * FROM Music')
rows[0].file.playLoop(0)          // straight out of the database

What's stored is the asset's own encoded bytes, so a round trip is byte-identical — an MP3 stays an MP3 rather than becoming a much larger WAV, and a JPEG stays a JPEG rather than being re-encoded as PNG. Reading a row decodes it back into a real handle, so the value that comes out behaves exactly like one loaded from a file.

The one case with no source bytes is an image you built rather than loaded — a clip() or resize() result. Those are encoded as PNG on demand, which is lossless. A blob column works the same way and is the general case: any file, not just the two the language decodes — see Blobs in the database for what a blob read back out of a column can and cannot do. Binding is by value: the parameter is copied into the database as the statement runs, so nothing is retained afterwards and the asset stays yours.

Query performance #

Two things happen automatically. A sqlite() call whose SQL is a string literal is prepared once and reused — parsing and planning happen on the first call only, so a query in a loop pays for binding and stepping, nothing else. (Dynamic SQL — a template or a variable — is prepared per call, since it can differ each time.) And the database opens in WAL mode with synchronous=NORMAL, the standard application configuration: measured, 20,000 inserts dropped from 16.7s to 0.3s. A transaction survives an application crash; only an OS crash or power loss can lose the most recent commits — never corrupt the file.

Partial queries and undefined() #

Result columns are matched to the table's declared columns by name (case-insensitively), not by position — so a query may select any subset of columns, in any order, and every value lands where it belongs. A column the query didn't mention reads as null.

But "the query never asked" and "the database said NULL" are different facts, and row.undefined('col') tells them apart:

table examples { id:int  name:text }
arr[examples] data = sqlite('select id from examples')

if data[0].name == null && data[0].undefined('name') {
    // name is null because it wasn't selected -- not because the
    // database has no name for this row
}

undefined('col') is true when the column wasn't in the result set (or was deleted off the row), false when the database genuinely returned a value or a NULL. Asking about a column the table doesn't declare fails the program — that's a typo, and true or false would both bury it. A SELECT ... AS alias renames a column away from its declared name, so an aliased column simply doesn't match; alias to a declared name to remap a computed value into a column deliberately.

row.rowid — a table row's own database identity #

Every ordinary SQLite table already has a rowid — this exposes it, read-only, so upserting by key doesn't mean hand-tracking the next id yourself:

table examples { id:int  name:text }
sqlite('INSERT INTO examples (id, name) VALUES (?, ?)', [1, 'ada'])
arr[examples] rows = sqlite('SELECT rowid, id, name FROM examples')
log(rows[0].rowid)   // 1

Like any other column, rowid only lands if the query's own SQL selects it by that name — SELECT * does not implicitly include it, so a query that never asks for rowid reads it as null, the same "the query never mentioned this" signal undefined() gives an ordinary column. It is not itself a declared column, so it never participates in schema sync and undefined('rowid') doesn't apply to it. It doesn't exist on a struct query target — rowid is a table row's own identity, not a property sqlite() can produce for an arbitrary result shape.

Structs as query targets #

A query doesn't have to land in a table's row type. Any struct whose fields are queryable types (int/float/bool/text/blob/img/aud) can receive a result — name its fields after the result's own column names:

struct data {
    whatever:int
}
arr[data] query = sqlite('select id as whatever from examples')
log(query[0].whatever)

This is the shape for aliased columns, JOINs, and computed results — a table's declared columns can never chase a query's aliases, and a table declaration always creates a table, which a result-only shape has no business doing:

struct summary { total:int  biggest:text }
arr[summary] agg = sqlite(
    'select count(*) as total, max(name) as biggest from examples')

The elements are ordinary structs — refcounted, aliasable, free-able, their fields assignable and delete-able, exactly as if built by hand. A field the result didn't produce reads null. One consequence: undefined() is a table-row method and doesn't exist here, since an ordinary struct carries no record of which query it came from.

Database configuration #

festina.sqlite is the default, but the entry file's very first line (before any other code and before any import) may override it:

DatabaseURL = 'game_saves.sqlite'

path may be any text expression, including environment.NAME (see Environment variables below) — useful for picking the database path per-deployment without recompiling:

DatabaseURL = environment.DATABASE_URL
Note: DatabaseURL appearing anywhere other than the entry file's first statement is a compile-time error; it has no effect at all in an imported file (only the file actually passed to the compiler is checked).

Single-value queries #

A struct — unlike a table — declares only a shape, not a real table, so it costs nothing to use as a one-off landing spot for a value that would otherwise need a throwaway table sitting in your database forever just to receive a count(*):

struct Total { total:int }
struct Name  { name:text }
struct Mean  { mean:float }

arr[Total] t = sqlite(`SELECT count(*) AS total FROM Post`)
int total = t[0].total

arr[Name] n = sqlite(`SELECT title AS name FROM Post WHERE id = ?`, [2])
text name = n[0].name

arr[Mean] m = sqlite(`SELECT avg(score) AS mean FROM Post`)
float mean = m[0].mean

Alias the column to match the struct's field name (AS total, AS name, ...) the same way any sqlite() query does. A query matching no rows comes back as an empty array (arr.length == 0) rather than a value — check that before indexing [0].

Both are ordinary SQL, and sqlite() passes SQL through untouched — so SQLite's JSON1 and FTS5 work today with no extra language feature:

struct Name { name:text }
arr[Name] n = sqlite(`SELECT json_extract(data, '$.name') AS name FROM Doc WHERE id = ?`, [1])
log(n[0].name)

sqlite(`CREATE VIRTUAL TABLE PostSearch USING fts5(title, body, content='Post', content_rowid='id')`)
sqlite(`INSERT INTO PostSearch(PostSearch) VALUES('rebuild')`)
struct Total { total:int }
arr[Total] t = sqlite(`SELECT count(*) AS total FROM PostSearch WHERE PostSearch MATCH ?`, ['machine'])
log(t[0].total)

Environment variables #

text apiKey = environment.API_KEY
text home = environment['HOME']       // computed key -- must be text

if apiKey == null {
    fail('API_KEY is not set')
}

Returns the named environment variable as text, or null if it isn't set. Read-only (assigning to environment.NAME is a compile-time error) and can't be used by itself without a .NAME/[keyExpr] — both are also compile-time errors, not runtime ones.

Command-line arguments #

log(argv.length)
log(argv[0])          // the program's own path, same as C's argv[0]
if argv.length > 1 {
    log(`first arg: ${argv[1]}`)
}

argv is a real arr[text] global, populated from the process's own OS argc/argv before any top-level statement runs — no declaration needed. Unlike environment, it's an ordinary mutable array once populated: argv.push(...), argv[i] = ..., and every other array/growing array operation work on it normally. Works under --target=wasm32-wasi too (WASI has its own argc/argv), but this checkout's own runner (run_wasi.mjs) only ever passes the compiled module's own path through, so argv there is always a single-element array — see wasm.md.

Regex #

regex digits = /[0-9]+/                    // literal syntax, POSIX extended regex underneath
regex ci     = /^hello$/i                  // 'i' = case-insensitive
regex all    = /[0-9]/g                    // 'g' = replace every match
regex both   = /test/gi                    // flags combine

digits.test('room 42')                     // -> bool
'room 42'.match(digits)                     // -> text or null
'a1b2'.replace(/[0-9]/, 'x')                // 'a1b2' -> 'axb2'  (first match)
'a1b2'.replace(/[0-9]/g, 'x')               // 'a1b2' -> 'axbx'  (every match)

flags immediately follows the closing /, no space (/pattern/flags). Only i and g are accepted; any other flag letter is a compile-time error. \w/\d/\s (and their negations) and \b work as expected on every platform — the runtime expands them to portable POSIX classes before compiling, not relying on any one C library's extensions — but there are no capture groups, backreferences, or non-greedy quantifiers (POSIX ERE's own limits). Inside [...] a backslash is a literal, per POSIX.

What g does, and what it doesn't #

g affects .replace() and nothing else.

'a-b-c'.replace(/-/g, '_')     // 'a_b_c'
'a-b-c'.replace(/-/, '_')      // 'a_b-c'
'a-b-c'.replace('-', '_')      // 'a_b-c' -- a text search has no flags

A plain-text search replaces the first match only. Replacing every occurrence is spelled /search/g — there is no separate all-matches method. g is scoped narrowly on purpose: .test() returns the same answer every time against the same string rather than carrying match-position state across calls, and .match() always returns text (or null) — a return type can't depend on a flag that regex(pattern, flags) only knows at run time, so g has no effect there.

A pattern/flags that aren't known until runtime (built from a variable or a template) can't use the literal syntax — the global regex(pattern, flags) function is still available for that case: a literal for the common, known-at-compile-time pattern, a function call for anything assembled dynamically:

text userPattern = someInput()
regex dynamic = regex(userPattern)
regex globalDynamic = regex(userPattern, 'g')   // 'g' works here too

The flag belongs to the compiled pattern, not to the call site, so both spellings behave identically.

Literals are compiled once; regex() is memoized per call site #

A /pattern/ literal is compiled the first time its line is reached and cached for the life of the process. A regex(pattern, flags) call is memoized per call site: each call compares its actual pattern and flags against what that site compiled last time, reuses the compilation when they match, and recompiles when they differ. A pattern that varies per call is never served a stale automaton — the check is against the runtime strings, not the source location.

So the steady-state cost matches the literal's. Measured over 200,000 iterations, regex('[0-9]+').test(s) inside a loop runs in ~15 ms, the same as /[0-9]+/.test(s) — before the memo it was ~367 ms (one full compile per iteration, roughly 24x). A loop that genuinely alternates patterns through one call site still pays a recompile per change, since the memo keeps only the most recent compilation per site; bind each pattern to its own variable outside the loop if that matters.

Graphics #

drawRect(0, 0, 100, 100)
drawRect(0, 0, 100, 100, blue)           // optional trailing color -- this call only
drawRect(0, 0, 100, 100, blue, red)      // trailing fill AND border color -- this call only
drawPixel(10, 10)                        // one pixel, current fillStyle
drawPixel(10, 10, blue)                  // one pixel, this call only
drawCircle(50, 50, 25)
drawCircle(50, 50, 25, blue)             // fill override, this call only
drawCircle(50, 50, 25, blue, red)        // fill AND border override, this call only
drawText('Hello', 20, 20)

img profile = 'profile.png'              // PNG or JPEG
drawImage(profile, 0, 0)
drawImage(profile, 0, 0, 64, 64)         // scaled to fit a 64x64 box
drawImage(profile, 0, 0, 32, 32, 100, 100, 64, 64)  // source rect, scaled into dest rect
log(`${profile.width}x${profile.height}`)

img brush = blankImage(64, 64)           // a fresh, fully-transparent image
brush.drawCircle(32, 32, 30, blue)       // draw onto it like any other img

color picked = getPixelColor(10, 10)     // read one canvas pixel back
color picked2 = brush.getPixelColor(32, 32)  // or one img pixel

saveCanvas('screenshot.png')             // -> bool; writes what you drew
img snap = saveCanvas()                  // -> img; a snapshot, no file written

render()                                  // put the canvas on screen
clearCanvas()                             // erase everything to transparent
clearRect(10, 10, 40, 40)                 // erase one region to transparent
clearCircle(50, 50, 25)                   // erase a circular region to transparent
clearPixel(10, 10)                        // erase one pixel to transparent

log(`canvas is ${clientWidth}x${clientHeight}`)

log(`screen is ${screenWidth}x${screenHeight}`)  // the physical display, read-only
log(`device pixel ratio is ${devicePixelRatio}`) // 1.0 normally, ~2.0 on Retina/HiDPI
setClientWidth(1024)                             // resizes the canvas (and window, if open)
setClientHeight(768)

on mouseDown(x:int, y:int, button:int) { ... }
on mouseUp(x:int, y:int, button:int)   { ... }
on mouse(x:int, y:int)         { ... }
on mouseWheelUp(x:int, y:int)  { ... }
on mouseWheelDown(x:int, y:int){ ... }
on keyDown(key:text)           { ... }
on keyUp(key:text)             { ... }
on resize()                    { ... }
on close()                     { ... }

drawImage has three forms. drawImage(img, x, y) draws it at its stored size. drawImage(img, x, y, w, h) scales the whole image to fit a w×h box at (x, y) — unlike img.resize(), this doesn't touch the image itself, so the same img can be drawn at as many different sizes as you like. drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) adds a source rectangle — cuts a sw×sh region out of the image starting at (sx, sy) and scales that into the dw×dh destination box — the way a sprite sheet or a variable-size paint brush pulls one piece out of a larger stored image without a separate .clip() call first. A source region reaching past the image's own edge behaves like .clip()'s own: the overlap is drawn, the rest is simply not there. All three forms (and both img.drawImage forms) also accept a manually-managed img? as the source — compositing only reads it, so a layer a worker thread painted can be drawn directly, with no clip() copy first (see T?).

blankImage(w, h) returns a fresh, fully-transparent img at the given size — with no existing image or canvas to derive it from, unlike .clip()/.resize()/saveCanvas(), every one of which copies from something that already exists. Useful for building up a procedural image (a generated icon, a variable-size paint brush) from nothing, without touching the real on-screen canvas along the way.

drawRect/drawCircle (and their img method equivalents) each accept a further optional trailing borderColor, after the fill color — paints the border with it for that one call only, leaving the current borderColor() untouched for every other shape, the same "this call only, then restore" contract the fill-color argument already has. drawCircle gained BOTH trailing forms here — it previously had no per-call color override at all. This is the direct fix for global draw style silently leaking between unrelated shapes: a border color left over from a previous, unrelated drawRect/drawCircle call no longer has to be reset with borderColor() or saveState()/restoreState() by hand before every shape that needs its own.

getPixelColor(x, y) reads one pixel back off the canvas, and img.getPixelColor(x, y) reads one back off an img's own surface — useful for a color picker, or any effect that needs to know what's already been drawn somewhere. Both answer null for a coordinate outside the canvas/image's own bounds, and for a fully transparent pixel (nothing painted there, or painted then cleared) — the same null a color variable with no assigned color already reads as. Where a pixel was painted at less than full opacity (fillAlpha), the answer is the color that was actually painted, not one darkened by whatever alpha was in effect — Cairo stores color and alpha together (premultiplied), and this un-does that before handing the color back.

bool pickerOn = true
color picker

on mouse(x:int, y:int) {
    if pickerOn {
        picker = getPixelColor(x, y)
    }
}
Drawing is offscreen. render() puts it on screen. Every drawing call paints an offscreen canvas that needs no display at all. render() is the one call that shows it, opening a real, decorated window (title bar, and the OS's normal minimize/maximize/close controls — like any other window, resizable by dragging an edge) the first time it runs — 800×600 by default. Declaring one of the nine event handlers means a window will exist too, since they can't fire without one — but not necessarily at that point: if the entry file never itself calls render(), the window instead opens lazily right after the entry file's own top-level code finishes, just before the process starts blocking on redraws/input. Either way, whatever clientWidth/clientHeight (or setClientWidth/setClientHeight, below) already are BY THEN is the size the window opens at — see setClientWidth/setClientHeight's own note just below for why this matters. After the entry file's top-level code finishes, if a window was opened, the process blocks handling redraws/input until the window closes.
Event handlers are active as soon as they're declared, regardless of where in the file that is — the same hoisting text func/void func declarations already get, applied to on ... too. setClientWidth/setClientHeight fire on resize synchronously, inline, at the point they're called — not later, and not only once the entry file has finished running top to bottom — so a call to either one, anywhere above an on resize handler that reads global state initialized further down the file, can run that handler against state that hasn't been set up yet:
render()
setClientWidth(400)     // on resize fires HERE, inline

arr[int] data = [1, 2, 3]   // this hasn't run yet when it fires
on resize() {
    log(data.length)        // reads 0, not 3
}
Nothing about this is specific to resize — every event handler is registered before the entry file's own top-level code runs at all (mouse/key events simply can't fire that early in practice, since they need real user input after a window exists, but on resize can be triggered programmatically by the very first line of the file). The fix is ordinary top-to-bottom discipline: declare a handler, and initialize whatever global state it reads, before any call that could plausibly trigger it.

That split means two useful things:

// No display needed. No window. Exits on its own.
fillStyle(brand)
drawRect(0, 0, 100, 100)
saveCanvas('chart.png')
// A frame: draw everything, then present once.
clearCanvas()
drawSprites()
render()

Batching matters — a frame of 2000 rectangles behind one render() call takes ~1ms. Nothing but render() and the event handlers needs a display — saveCanvas, clientWidth/clientHeight and loading an image all work headless.

A fresh or cleared canvas is transparent, not white — matching the HTML5 canvas model this otherwise mirrors. clearCanvas/clearRect/clearCircle/clearPixel all clear to fully transparent, and a canvas that's never been drawn on starts that way too:
drawRect(0, 0, 100, 100)
clearRect(20, 20, 20, 20)   // that region is now transparent
saveCanvas('sprite.png')    // a real alpha channel, usable as an asset
That transparency is real alpha in whatever saveCanvas() produces (a file or the img snapshot both), not something flattened to a solid colour — useful for drawing a sprite or icon with a transparent background to compose elsewhere.
That real alpha channel is only real off-screen. render() composites the canvas onto an ordinary opaque on-screen window, so a transparent region reads back as solid white once it's on screen, even though the same content saved or snapshotted via saveCanvas() still carries its real alpha. That makes two very different bugs look identical on screen — "my background never drew" and "my background drew transparent, which paints as opaque white" — so if a shape seems to be missing, check whether it's actually there but transparent (saveCanvas() it and inspect the alpha) before assuming the draw call itself did nothing.

saveCanvas() with no argument returns an img instead of writing a file — a snapshot of the canvas at that instant, not a live view of it: drawing or clearing the canvas afterward never changes what the snapshot holds.

drawRect(0, 0, 100, 100)
img snap = saveCanvas()
clearCanvas()
snap.save('before-clear.png')   // still has the rectangle

screenWidth/screenHeight report the physical display's own resolution — not the window's content size (that's clientWidth/clientHeight), since a window can be, and usually is, smaller than the screen it's on. Both are read-only. Unlike clientWidth/clientHeight, reading them still needs an X server (there's no window yet to answer from, and no other way to ask "how big is the screen"), so this is one of the few graphics reads that fails without a display.

devicePixelRatio reports how many actual device pixels back one canvas pixel — 1.0 on a standard display, typically 2.0 (or a fractional value like 1.5) on a Retina/HiDPI one. Read-only, same "needs a display" requirement as screenWidth/screenHeight just above, since it's a property of the physical display too. Purely informational — the canvas itself is never actually rendered at the higher resolution, so this doesn't change how big anything you draw appears; it just tells a program what's true about the display it's running on, the same way screenWidth/screenHeight do.

setClientWidth(int)/setClientHeight(int) resize the canvas — and the real OS window too, if one is already open. Both apply immediately: setClientWidth(400) is followed by clientWidth already reading 400, not whatever it was a moment before. A non-positive size is silently ignored. If a window is open, the resized content is cleared to transparent (matching clearCanvas's own behavior) and on resize fires once per call:

render()
setClientWidth(1024)   // window resizes; on resize fires once
setClientHeight(768)   // fires again
Calling either one before any window exists just picks the window's initial size — it opens directly at whatever clientWidth/clientHeight already are by then, not the 800×600 default, and on resize does not fire (there's no real resize, since the window never existed at any other size to begin with):
on resize() {
    log('resized')   // never runs for the two lines below
}

setClientWidth(1024)    // no window yet -- just updates clientWidth
setClientHeight(700)    // same
render()                 // opens directly at 1024x700
This is the reasonable, documented pattern — set the size you want, then start drawing — and it behaves exactly like you'd expect: no window flashes open at the 800×600 default first and then jumps to the requested size a moment later.

enterFullscreen()/exitFullscreen() #

enterFullscreen()/exitFullscreen() toggle true OS fullscreen — the window covers the whole screen, decorations included, exactly like using the OS's own fullscreen control (macOS's green zoom button, double-clicking a Windows title bar's maximize equivalent, or an X11 window manager's own fullscreen keybinding) would. Calling either one before the window has ever opened just picks the window's initial state, the same as setClientWidth/setClientHeight above — a program that wants to launch straight into fullscreen calls enterFullscreen() before its first render(), and never sees a normal window at all:

enterFullscreen()
drawRect(0, 0, 100, 100)
render()                 // opens directly in fullscreen

Unlike setClientWidth/setClientHeight, the resulting size change is not immediate — entering or exiting fullscreen is a real negotiation with the OS/window manager, not something Festina does to itself, so clientWidth/clientHeight (and on resize, if declared) only update once that negotiation finishes, on the next pass through the event loop — not synchronously at the enterFullscreen()/exitFullscreen() call site the way setClientWidth is. Calling enterFullscreen() while already fullscreen (or exitFullscreen() while not) is a no-op. Exiting always restores the exact window the program had immediately before entering — same size and position, not just "some reasonable windowed size".

showCursor()/hideCursor() toggle the mouse cursor's visibility over the canvas — useful for a game that draws its own cursor/reticle, or one that just doesn't want the OS pointer cluttering the screen. Unlike render()/enterFullscreen()/exitFullscreen(), neither forces a window open — a cursor is meaningless without one, so calling either before the window exists just records the desired state for whenever it does, the same "picks the initial state" pattern setClientWidth/enterFullscreen already have:

hideCursor()
drawImage(reticle, mouseX - 8, mouseY - 8)   // your own cursor instead
render()

Calling hideCursor() while already hidden (or showCursor() while already shown) is a no-op.

Mouse events #

on mouseDown fires when a button goes down, on mouseUp when it comes back up, and on mouse continuously while the pointer moves. All three report the pointer position at the moment the event happened; mouseDown/mouseUp also report which button.

A click is a press and a release, and they are separate events for the same reason keyDown and keyUp are: holding the button down and moving before letting go is a drag, and the only way to see one is to see both ends of it.

int startX = 0
int startY = 0

on mouseDown(x:int, y:int, button:int) { startX = x  startY = y }
on mouseUp(x:int, y:int, button:int)   { log(`dragged ${x - startX}, ${y - startY}`) }

Press and release report different coordinates whenever the pointer moved in between — that difference is the drag. A program that only wants "was clicked" can just use on mouseDown and ignore the release.

button is 1 for the left button, 2 for middle, 3 for right, 8 for back and 9 for forward (on a mouse that has them) — any other physical button reports its own platform-specific number, best-effort. on mouse (continuous movement) has no button of its own to report, so it stays a plain (x:int, y:int).

on mouseDown(x:int, y:int, button:int) {
    if button == 1 { log('left click') }
    if button == 3 { log('right click, e.g. for a context menu') }
}

on mouseWheelUp/on mouseWheelDown fire once per scroll wheel notch (or, on a trackpad, once per equivalent step of a two-finger scroll), split by direction the same way mouseDown/mouseUp are split by press/release rather than one combined event. Both report the pointer's position at the moment of the scroll, the same convention as every other mouse event above — but, unlike mouseDown/mouseUp, no button, since the wheel isn't one:

on mouseWheelUp(x:int, y:int)   { log(`zoom in at ${x}, ${y}`) }
on mouseWheelDown(x:int, y:int) { log(`zoom out at ${x}, ${y}`) }

How far one scroll "step" is is up to the OS/input device, not something a program can read — there's no delta or magnitude, only direction.

Keyboard events #

on keyDown fires when a key goes down, on keyUp when it comes back up. Both report the same name for the same physical key: a key that types a character gives you that character ('a', '5', ' '), and anything else gives you X11's own name for it ('Left', 'Escape', 'Return', 'space' is ' '). So a release can always be matched against the press that started it:

map[bool] held = {}

on keyDown(key:text) { held[key] = true }
on keyUp(key:text)   { held[key] = false }
Holding a key fires one keyUp, when you actually let go. X's own auto-repeat would otherwise synthesize a release before every repeat, which would make the pair useless for exactly the movement keys it exists for; the runtime turns that off where the server supports it and filters it out where it doesn't. keyDown does repeat while a key is held — that is how text entry works, and a program that only wants the first press can check whether it has already seen that key go down without a matching up.

Images #

img sheet = 'spritesheet.png'             // PNG or JPEG
log(`${sheet.width}x${sheet.height}`)

img grass = sheet.clip(0, 0, 64, 64)     // a new 64x64 image
grass.resize(32, 32)                      // scaled in place
drawImage(grass, 100, 100)
grass.save('grass.png')                   // -> bool; see Saving bytes

A path declares the image, the same way it declares an aud — and, like that one, it's a real load rather than a compile-time resolution, so the path may be any text expression (img hero = spriteDir + 'hero.png'). save()/saveCopy() write one back out; see Saving bytes to a path.

PNG and JPEG. The format is sniffed from the file's contents, not its extension — an image out of a database column has no extension, and an extension was never evidence of anything anyway. Loading needs no display: decoding is pure computation, so a headless program can load, clip, resize and saveCanvas without an X server.

img.width / img.heightCurrent size in pixels, as int.
img.clip(x, y, w, h)A new img holding that rectangle. The source is untouched, so one sheet can be clipped as many times as you like.
img.resize(w, h)Scales the image in place — it changes the image itself, so every name for it sees the new size.
img.drawRect(x, y, w, h[, color[, border]]) / img.drawPixel(x, y[, color]) / img.drawCircle(x, y, r[, color[, border]]) / img.drawText(text, x, y)The same four canvas-level drawing calls, painting onto this image's own surface instead.
img.translate(dx, dy) / img.rotate(degrees) / img.scale(sx, sy) / img.resetTransform()This image's own transform — identity from creation, independent of the canvas's — applied to everything drawn, cleared or composited onto it afterwards.
img.saveState() / img.restoreState()Push/pop this image's own transform (style state is global and stays with the canvas's saveState()). restoreState() with nothing saved is an error, like the canvas's.
img.clear() / img.clearRect(x, y, w, h) / img.clearCircle(x, y, r) / img.clearPixel(x, y)Erase to transparent (alpha 0, so whatever is later drawn underneath shows through). clear() ignores the image's transform, exactly as clearCanvas() ignores the canvas's; the three region forms honour it.
img.drawImage(src, x, y) / img.drawImage(src, x, y, w, h)Composite src onto this image at (x, y) in this image's coordinates, through this image's transform, honouring fillAlpha; the five-argument form scales src to fit w×h. Drawing an image onto itself copies it first.
img.getPixelColor(x, y)One pixel read back off this image's surface (null when transparent or out of bounds) — the img form of getPixelColor above.

clip is the spritesheet operation: one PNG holding a grid of frames, sliced into the individual images you draw.

img sheet = 'tiles.png'
arr[img] tiles = []
for int i = 0, i < 8, i++ {
    tiles[i] = sheet.clip(i * 32, 0, 32, 32)
}

A clip region reaching past the source's edge isn't an error — the overlapping part is copied and the rest stays transparent, which is normal at a sheet's right or bottom margin. A zero or negative width or height is an error, since it could only ever produce an image nothing can draw.

Drawing onto an image uses the same style state as the canvas (fillStyle, borderColor, lineWidth, fillAlpha, changeFont) and the same optional trailing colours on drawRect/drawPixel/drawCircle — but nothing else about the canvas. No window is needed (an image's surface already exists in full the moment the image does), and the canvas's own translate/rotate/scale transform is never applied — an image is a portable asset with its own local pixel coordinates, independent of whatever the canvas's transform happens to be set to:

color red = 'red'
color blue = 'blue'
img icon = 'blank.png'
fillStyle(red)
icon.drawRect(0, 0, 16, 16)
icon.drawPixel(24, 8, blue)      // this pixel only -- fillStyle stays red after
icon.save('icon-with-border.png')
An image as a layer. What an image does have is a transform of its own, a way to erase part of itself, and a way to take another image — the three things that make it a self-contained drawing target rather than something you bounce through the canvas (blit in, draw, saveCanvas, clip) to edit. Every one of these mirrors the canvas call of the same name and touches only the image it's called on, so a layer can be painted from wherever it lives — a worker thread included:
// A rotated brush stroke, painted straight into a chunk layer
img layer = blankImage(384, 384)
layer.saveState()
layer.translate(sx, sy)
layer.rotate(strokeAngleDeg)                   // degrees, like the canvas
layer.drawRect(-halfW, -halfH, sw, sh)         // rotated about (sx, sy) ON THE IMAGE
layer.restoreState()                           // layer back to identity; canvas untouched

// An eraser
layer.clearCircle(localX, localY, radius)      // to transparent, not "paint black"

// Building a background once from tiles, no canvas involved
img bg = blankImage(CHUNK_PX, CHUNK_PX)
for int ty = 0, ty < CHUNK_TILES, ty++ {
    for int tx = 0, tx < CHUNK_TILES, tx++ {
        bg.drawImage(grassTile, tx * TILE, ty * TILE)
    }
}

// Stamping a sprite into a layer with a sway rotation already applied
layer.saveState()
layer.translate(pivotX, pivotY)
layer.rotate(swayDeg)
layer.drawImage(sprite, drawX - pivotX, drawTopY - pivotY)
layer.restoreState()
The image's transform applies to its drawRect/drawPixel/drawCircle/drawText, to clearRect/clearCircle/clearPixel, and to drawImage — the same set the canvas's transform applies to on the canvas. img.saveState()/img.restoreState() push and pop only that image's transform: style state (fillStyle, borderColor, lineWidth, fillAlpha, font) is global and stays with the canvas's own saveState(). img.clear() wipes the whole image to transparent regardless of its transform (as clearCanvas() does for the canvas); the region-shaped clears go through it. img.drawImage honours fillAlpha exactly as the canvas drawImage does, and drawing an image onto itself is fine — the source is copied first, so tiling an image with shifted copies of itself just works. A thread that receives an image as a message gets its own copy with the same current transform and an empty state stack.

Because resize changes the image itself, two names for one image stay in step:

img a = sheet.clip(0, 0, 32, 32)
img b = a
a.resize(8, 8)
log(b.width)      // 8 -- a and b are the same image

An image created in a function (from a path, or by clip) and never stored outside it is released when that function returns, so slicing frames inside a loop doesn't accumulate.

Drawing style #

color brand = '#4a90d9'
color line = 'gray'
font  body  = 'bold 20px serif'

fillStyle(brand)            // fills: drawRect, drawPixel, drawCircle, drawText
borderColor(line)           // outlines drawRect/drawCircle
lineWidth(4)                // border thickness, in pixels
changeFont(body)            // used by drawText and both measure calls

Style is set once and applies to every later draw — the same model the HTML canvas uses. Defaults are black fill, no border, and 16px sans-serif, so a program that never calls these draws exactly what it did before they existed.

drawRect/drawPixel take an optional trailing color that overrides fillStyle for that one call only — the current fill (a flat color or an active gradient) is unaffected afterward:

fillStyle(brand)
drawRect(0, 0, 20, 20)        // brand
drawRect(30, 0, 20, 20, line) // line, just this once
drawRect(60, 0, 20, 20)       // brand again

borderColor/lineWidth still apply as configured either way — only the fill is a per-call override, not the border.

The common case never touches a rasterizer. An opaque flat-colour drawRect, drawCircle or drawPixel at an integer position — no fillAlpha below 1, no gradient, no border, no scale/rotate, at most a whole-pixel translate — is written straight into the pixels, on the canvas and on an img alike (circles from a per-radius coverage mask that Cairo rasterizes once). The pixels are byte-identical to what the Cairo path produces; it is just several times faster, which is what makes a frame of thousands of shapes cheap (see benchmark.md). Anything outside that contract goes through Cairo exactly as before. FESTINA_NO_DIRECT_FILL=1 in the environment switches the direct path off for a whole program — the test suite uses it to check the two agree, and it is the escape hatch should they ever not on some platform.
Colors and fonts must be declared. Anything other than raw RGB numbers has to be a color or font declaration first:
color red = 'red'      // then: fillStyle(red)
font  body = '14px'    // then: changeFont(body)
fillStyle('red') and changeFont('14px') do not work. The declaration is where the compiler resolves the name, once — after that a color is just a packed integer and a font is a pointer to a constant, so using either costs nothing. If a color is chosen dynamically, use fillStyle(r, g, b) — see Computing a color or font at runtime below. There is no way to turn a runtime text value into a color or a font, and attempting it is a compile error that says so.

The color type

color red   = 'red'
color brand = '#4a90d9'
color ghost = 'none'

A color literal is any of the 148 CSS color names (red, teal, rebeccapurple, lightgoldenrodyellow, …), a #rgb or #rrggbb hex value, or none/transparent. Names are case-insensitive and #abc expands to #aabbcc, both as in CSS.

The declaration is where the name is resolved: color red = 'red' becomes the packed integer 0xFF0000 at compile time, so nothing parses a color string while your program runs. A name the compiler doesn't recognize is a compile error naming the value and its line — it can't reach a running program, and it never silently falls back to black. A color is an ordinary value after that: assign it, pass it to a function, return one. It is a plain integer, so it is never reference-counted and costs nothing to copy.

none works on both setters: as a fill it leaves a shape's interior untouched, so borderColor alone gives an outline-only shape; as a border color it switches borders back off.

color none = 'none'
color ring = 'purple'

fillStyle(none)
borderColor(ring)
lineWidth(8)
drawCircle(200, 200, 60)    // a purple ring, nothing inside it

borderColor outlines shapes only, not the glyphs drawText draws.

The font type

font body  = 'arial 14px bold'   // all three parts
font same  = 'bold 14px arial'   // any order — identical result
font small = '14px'              // just the size; family/style unchanged
font mono  = 'monospace'         // just the family; size unchanged

A font literal takes the CSS/canvas shorthand with words in any order, and any part may be omitted — italic/oblique set the slant, bold the weight, a bare number or <n>px the size, and the first word that is none of those is the family. An omitted part means "leave that alone", which is what lets font small = '14px' change only the size.

Each distinct font compiles to a constant in the binary's read-only data, so declaring one costs nothing at runtime and changeFont() passes a single pointer. Identical fonts share one constant, so body and same above are literally the same record. An empty literal (font f = '') is rejected — it says nothing, and is far likelier to be a mistake than an intent.

Computing a color or font at runtime #

There is deliberately no way to turn a runtime text value into a color or a font — resolution happens at the declaration, so the declaration needs a literal. To choose either from values you compute, use the explicit numeric forms, which are strictly more capable for that job anyway (they take any int expression, where a color name could only ever have named one of a fixed set):

fillStyle(r, g, b)                // each 0-255; a negative value means 'none'
borderColor(r, g, b)
changeFont(px, style, family)     // style/family may be null;
                                   // px <= 0 keeps the current size
// a gradient of swatches — the color is different every iteration
for int i = 0, i < 10, i++ {
    fillStyle(i * 25, 0, 255 - i * 25)
    drawRect(i * 40, 0, 36, 36)
}

// a font size that depends on runtime state
int size = 12 + level * 4
changeFont(size, 'bold', null)    // family left as-is

Passing a non-literal where a color or font is expected is a compile error that points at these forms.

Paths #

fillStyle(red)
beginPath()
moveTo(50, 50)
lineTo(150, 50)
lineTo(100, 140)
closePath()
fillPath()        // a filled triangle
beginPath()Starts a new path.
moveTo(x, y) / lineTo(x, y)Move the pen / draw a straight segment.
curveTo(cx1, cy1, cx2, cy2, x, y)A cubic bezier to (x, y).
closePath()Closes back to the start.
fillPath() / strokePath()Paints the path with the current fill / border colour, and ends it.

fillPath uses fillStyle; strokePath uses borderColor and lineWidth. Both consume the path, as fill()/stroke() do on a canvas — call beginPath() again for the next shape. Using moveTo and friends with no path open is a clean error naming the missing beginPath().

Transforms #

saveState()
translate(400, 40)
rotate(30.0)          // degrees
scale(2.0, 2.0)
drawRect(0, 0, 60, 60)
restoreState()        // transform (and style) back as it was

A transform applies to everything drawn after it, until changed. resetTransform() returns to the identity. saveState/restoreState save the whole drawing state — transform, colors, alpha, line width and font — matching the canvas save()/restore() they mirror. A restoreState() with nothing saved is an error rather than a silent no-op. Rotation is in degrees. Math.PI is there if you'd rather work in radians.

The canvas's transform never applies to drawing onto an img; an image carries its own instead — img.translate()/rotate()/scale()/resetTransform() and img.saveState()/restoreState(), the same calls as methods (see Images).

Gradients and transparency #

color a = 'red'
color b = 'blue'

fillLinearGradient(50, 300, a, 250, 300, b)   // start point, colour -> end point, colour
drawRect(50, 280, 200, 60)

fillRadialGradient(400, 300, 60, a, b)        // centre, radius, inner, outer
drawCircle(400, 300, 60)

fillAlpha(0.5)                                 // 0.0 transparent .. 1.0 opaque

A gradient replaces the flat fill until the next fillStyle(). Two stops rather than an arbitrary list — that covers essentially every gradient a program draws, and needs no separate gradient type.

fillAlpha applies uniformly to whatever's drawn next — every fill (drawRect/drawPixel/drawCircle/drawText, whether onto the canvas or directly onto an img's own surface) and drawImage, in both its canvas form and its img.drawImage form:

fillAlpha(0.4)
drawImage(sprite, 100, 100)   // blends 40% into whatever's underneath
fillAlpha(1.0)

Text metrics #

int w = measureTextWidth('Hello')
int h = measureTextHeight('Hello')

Both measure against the current font and return int. Neither opens a window — text metrics depend only on the font, so they work in a program that never draws, and with no X server at all. measureTextWidth is the advance width (how far the pen moves), which is what you want for laying strings out one after another — the same thing the canvas measureText().width reports. measureTextHeight is the inked height of that string, which is why it takes the text: 'x' is shorter than 'Xg'. For a stable line height independent of which letters appear, measure a string with both an ascender and a descender.

Files #

A file is a blob. Declaring one loads the bytes at that path, and keeps the path, so everything you can do to a file is a method on the value that already knows which file it is:

blob notes = 'notes.txt'              // loads the bytes at that path

notes.write('hello')                  // -> bool (did it land?)
notes.append(' world')                // -> bool
text body = notes.toText()            // -> the bytes, as text
bool there = notes.exists()           // -> bool
notes.delete()                        // -> bool; deletes the FILE
int size = notes.length               // -> int; the byte count

notes.save()                          // -> bool; write the bytes to its path
notes.save('other.txt')               // -> bool; adopt that path, then write
notes.saveCopy('backup.txt')          // -> bool; write there, keep its own path

.length is the exact byte count — unlike text.length, a blob has no UTF-8 structure to walk, so this is a plain stored count, not a scan. An unreadable path is 0, the same "empty blob" answer .exists()/ .toText() already give it.

The path may be any text expression, like img and aud: blob save = saveDir + 'slot1.dat'.

Nothing here fails the program. A path that can't be read gives you an empty blob, and the writers return false on failure — a missing file is something you test for rather than something that stops you, the same treatment division by zero gets. That is also how you create a file that doesn't exist yet: declare the blob and write to it.
blob fresh = 'new.txt'
log(fresh.exists())                   // false
fresh.write('now it does')
log(fresh.exists())                   // true

Loading in the background: .callback() #

blob key = 'path' reads the file synchronously, blocking until it's done — and img/aud work the same way. .callback() — on any text path expression, not just a literal, and for all three types — starts the read in the background instead, returning an empty (not-yet-loaded) value immediately and firing a callback once the read actually finishes, from the same main thread everything else in a Festina program runs on:

void func onLoaded(b:blob) {
    log(`loaded: ${b.toText()}`)
}

blob b = 'large-file.dat'.callback(onLoaded)
log('dispatched')                     // logs BEFORE onLoaded ever runs

callback must be func[blob]:void, func[img]:void, or func[aud]:void — whichever matches the declared type — called with the SAME value the declaration produced, mutated in place with the real content once it's been read (exactly the shape req.send()'s own callback already has — see Non-blocking requests above). When the response doesn't need a name, drop the variable and write the load as its own statement, prefixed with the target type purely for readability (it isn't otherwise required — .callback()'s own target type is already unambiguous from callback's signature):

blob 'large-file.dat'.callback(onLoaded)
img 'sprite.png'.callback(onImageLoaded)
aud 'theme.mp3'.callback(onClipLoaded)

An unreadable path, an unrecognized format, or corrupt file data all behave exactly like the synchronous form's outcome would if it could be observed without crashing the program — b.exists() is false and b.toText() is empty for a blob; an img stays a 1×1 transparent placeholder (.width/.height both 1); an aud stays silent (playing it is a harmless no-op). There's simply no separate "it failed" signal beyond that, matching blob's own existing "test, don't fail" contract; the whole point of callback is not reading the value until it fires. This is deliberately narrower than the synchronous form: img icon = 'bad.png' still fails the program outright on exactly those same three problems — .callback() only softens the failure because a background worker thread has no way to fail the program loudly in the first place.

toText() hands back an ordinary owned text, so it composes with everything else:

blob data = 'data.csv'
log(data.toText().replace(/,/g, ' | '))

A blob is its contents, not its path. write() and append() update the bytes as well as the file, so toText() after a write reports what you wrote. And delete() removes the file while leaving the blob alone — "delete it but keep what it said" is expressible:

blob temp = 'scratch.txt'
temp.write('remember this')
temp.delete()
log(temp.exists())                    // false
log(temp.toText())                    // remember this

Assigning a blob shares one handle, it does not copy. Two names for one file's contents; writing through either is visible through both. Rebinding one of them releases its own reference, and the contents are freed once nothing refers to them:

blob a = 'one.txt'
blob b = a                            // same handle, not a second load
a.write('changed')
log(b.toText())                       // changed

a = 'two.txt'                         // `a` moves on; `b` still holds one.txt

Blobs in the database #

A blob column stores the bytes, so binary content round-trips byte-identically — the same treatment img and aud columns get:

table Saves { name:text  data:blob }

blob save = 'slot1.dat'
sqlite('INSERT INTO Saves (name, data) VALUES (?, ?)', ['slot1', save])

arr[Saves] rows = sqlite('SELECT * FROM Saves')
log(rows[0].data.toText())

A blob that came out of a column has bytes but no path — a path is meaningful only on the machine that stored it. Its exists(), write(), append() and delete() all answer false rather than inventing a temporary file. toText() works as usual. save(path) is how one gets to disk — see Saving bytes to a path below, which is the same method on img and aud.

arr[Saves] rows = sqlite('SELECT * FROM Saves')
blob back = rows[0].data
log(back.exists())                    // false -- no path
back.save('recovered.dat')            // now it has one
log(back.exists())                    // true

Directories #

bool created = mkdir('./temp')        // -> bool: true if IT created it
arr[text] names = ls('./temp')        // -> arr[text] of entry names

mkdir(path) answers true only if it actually created the directory — false for every other outcome, including "it already existed", a missing parent, or no permission. Like the file builtins, nothing here fails the program:

mkdir('./temp')                       // true
mkdir('./temp')                       // false -- already there

ls(path) answers the directory's entry names (not full paths, and never ./..) as arr[text], in whatever order the OS hands them back. A missing or unreadable directory answers an empty array rather than failing:

arr[text] names = ls('./temp')
log(names.length)
log(ls('./nowhere').length)           // 0

Running other programs #

arr[text] cmd = ['/bin/echo', 'hello']
int status = exec(cmd)
log(status)                            // 0

exec(args:arr[text]):int spawns args[0] (searched on PATH the same way a shell finds it) with the rest of args as its own argv, inheriting stdin/stdout/stderr directly — it doesn't capture the child's output, the same "not a sandbox, this really runs it" model sqlite()/the file builtins already use for the filesystem. Blocks until the child exits and returns its real exit code, or -1 if the process never started at all (executable not found, no permission) — -1 is never a code the child itself could produce, so it's unambiguous. Not available under --target=wasm32-wasi — WASI has no process model to spawn into — rejected at compile time rather than failing at runtime; see wasm.md.

HTTP and WebSocket servers #

openPort(8080)

on request(req:http) {
    url u = parseURL(req.url)
    if u.pathname == '/hello' {
        req.send({'body': 'hello world'})
        return
    }
    req.ok()
}

openPort(port:int) starts listening for HTTP connections on port; closePort(port:int) stops. Neither fails the program — an already-open port, a privileged or in-use one, or closing a port never opened are all silent no-ops, the same "test, don't fail" convention mkdir()/exec() already use. A program is free to open more than one port.

Every connection is serviced from a single thread, the same "one thread total" model setTimeout/setInterval and graphics event handlers already use — connections are multiplexed, not run in parallel, so ordinary globals need no locking to read/write safely across requests. The tradeoff: a slow on request/on socketMessage handler delays every other connection's own turn. This is built for the kind of small, script-shaped server this language already targets, not a general-purpose production server replacement.

The http type #

http is a genuine value — construct one directly with a literal, the same shorthand a struct literal never gets (there is no {...} struct-literal syntax in this language; http is the one type built this way, because it's the value both the server (on request's own req) and the client (an outbound req.send(), below) share):

http {
    url:text       // e.g. 'http://example.com/path?a=1'
    method:text    // 'GET', 'POST', ...
    code:int       // the status code -- null until a response exists
    headers:map[text]
    callback:func[http]:void   // null means "block" -- see below
    // plus the methods documented below: ok()/redirect()/upgrade()/
    // send()/toText()/toBlob()/toImg()/toAud()
}

url/method/code/headers/callback are all read-only once constructed — the only way to set them is the literal itself:

map[text] headers = {'E-Tag': now().toText()}
http res = {'code': 200, 'body': 'ok', headers}    // {headers} is shorthand
                                                    // for {'headers': headers}

A literal accepts six keys, all optional: url/method (text, default ''), code (int, default null), headers (map[text], default empty), callback (func[http]:void, default null — see Making outbound requests below for what non-null actually does), and body — not a real field (there is no .body to read back later; it feeds straight into the value's content, read back through toText()/toBlob()/toImg()/toAud()) — accepting anything with a body form: text (sent as-is), int/float/bool (stringified, the same implicit conversion log() already does), a struct/table row/arr/map (rendered as JSON, the same .toText() every container already has), blob (sent as its own raw bytes), or img/aud (sent as the underlying encoded file bytes — unlike log()/templates/s.send(), a real HTTP body uploading or returning a picture or clip is completely ordinary, so neither is rejected here). Any other key is a compile-time error.

on request(req:http) #

Fires once per incoming HTTP request, fully parsed (request line, headers, and body already buffered — see Limitations below for what "fully parsed" doesn't include). A Transfer-Encoding: chunked body is decoded transparently into the same buffered body a Content-Length request already gets — req.toText()/.toBlob()/etc. don't need to know or care which one a client actually sent. req.code is null (no response exists yet); req.url is reconstructed from the connection's own scheme/Host header/path (falling back to 127.0.0.1:<port> if the client sent no Host header at all) — parse it with parseURL() (below) to pull out the path or query parameters.

req.url                                // text -- e.g. 'http://127.0.0.1:8080/hello?a=1'
req.method                            // text -- 'GET', 'POST', ...
req.code                              // int -- null on a live inbound request
req.headers                           // map[text] -- header names lowercased; a repeated
                                       // header's last occurrence wins

Responding — exactly one of the following ends the request; calling a second one on the same req is a silent no-op (never a crash, never a double response):

req.ok()                              // 200, empty body
req.redirect('https://example.com')   // 302, Location header set
req.send(res)                         // see below

req.send(res:http) — the SERVER form, taking exactly one already-constructed http value (an existing variable, or an inline literal: req.send({'code': 201, 'body': 'created'})) and sending it as this connection's response. res.code defaults to 200 if left unset in the literal; res.headers defaults to none. (req.send() — zero arguments — is a different call entirely: the CLIENT form, documented under Making outbound requests below; the two are told apart purely by arity.) If on request's own body returns without calling ok()/redirect()/send()/upgrade() at all, the connection still gets a response — a plain 200 with an empty body — rather than hanging the client forever.

Reading the body:

text t = req.toText()                 // the raw bytes, as text
blob b = req.toBlob()                 // the raw bytes, as a blob
img i = req.toImg()                   // decoded as an image (null if it isn't one)
aud a = req.toAud()                   // decoded as audio (null if it isn't one)

A request with no body answers an empty text/blob (never null), matching every other "nothing there" case in this language.

WebSocket: req.upgrade() #

on request(req:http) {
    url u = parseURL(req.url)
    if u.pathname == '/ws' {
        req.upgrade()
    }
}

on upgrade(s:socket) {
    log('client connected')
}

on message(s:socket, msg:blob) {
    s.send(`you said: ${msg.toText()}`)
}

on socketClose(s:socket) {
    log('client disconnected')
}

req.upgrade() performs the WebSocket handshake (RFC 6455) immediately and switches the connection over — nothing else about the request matters afterward (a call to ok()/send()/etc. on the same req is now a no-op, same as any second response attempt). If the request isn't actually a valid WebSocket handshake (missing/mismatched headers), upgrade() is a silent no-op and the connection falls through to the normal "no response sent" default (200, empty body) — never a crash.

Once upgraded, on upgrade(s:socket) fires once for that connection, then on socketMessage(s:socket, msg:blob) fires once per message received — always as a blob, whether the peer sent a text or binary frame (call .toText() if you know it's always text). on socketClose(s) fires exactly once when the connection ends, however it ends (the peer closed it, sent a close frame, or the read failed) — never for a plain HTTP connection that never upgraded.

Fragmentation is invisible to on socketMessage. A peer may split one logical message across several WebSocket frames (RFC 6455 §5.4) — this runtime reassembles them itself, so on socketMessage fires exactly once per MESSAGE either way, with the full, already-concatenated blob; there's no way to observe the individual fragments, and no reason to want to. A ping/pong or close frame arriving in the middle of another message's own fragments is answered/handled immediately without disturbing that reassembly (the RFC's own explicit allowance for interleaving control frames this way). A message whose peer never sends the closing fragment, an out-of-place continuation frame, or a reassembled message over 8MB, all close the connection with a real WebSocket close code (1002 protocol error, or 1009 message too big) — never a hang or a silent drop.

s.state                               // map[text] -- a per-connection scratchpad,
                                       // starts empty, persists for the connection's
                                       // whole lifetime
s.state['user'] = 'ada'               // read/write like any other map[text]

s.send(data)                          // data:any -- same sendable types as an http
                                       // literal's own 'body' key, minus the code/
                                       // headers (a frame has neither); blob sends a
                                       // binary frame, everything else text
s.close()                             // sends a close frame and ends the connection

Making outbound requests #

There is no separate fetch() builtin — req.send(), called with zero arguments, is the client form of the exact same method req.send(res) uses on the server side (above); which one applies is decided purely by how many arguments the call has.

img profile = 'profile.png'
http req = {'url': 'http://example.com', 'method': 'POST', 'body': profile,
            'headers': {'authorization': 'bearer example'}}
req.send()                            // blocks until the response arrives, then
                                       // REPLACES req's own body/code/headers with it
log(req.code)                         // e.g. 200
log(req.toText())

req.send() resolves req.url's host, connects (TLS automatically for an https:// URL, plain TCP for http:// — the scheme is read from the value at runtime, so both are always linked into any program that calls req.send() at all), sends req.method/req.headers/whatever body the literal was given as one HTTP/1.1 request, and blocks until the whole response arrives (this runtime is single-threaded, the same tradeoff setTimeout/on request itself already accepts: a slow outbound request delays every other connection's own turn for as long as it takes). req.url/req.method are left untouched; req.code, req.headers, and the body read back through req.toText()/toBlob()/toImg()/toAud() are all overwritten in place with the response. A genuine network failure — the host doesn't resolve, the connection is refused, the TLS handshake fails, or the response can't be parsed as HTTP — throws (catch it with try/catch), the same "this can really fail, with real diagnostic text" precedent toStruct()/toArr()'s JSON parsing already established, rather than the "test, don't fail" convention most of this runtime's I/O uses. There's no timeout to configure — a 30 second socket timeout bounds the worst case.

Calling req.send() a second time on the same value sends a second, independent request (using whatever url/method/headers/body it currently holds, response overwrite and all) — nothing about the zero-argument form is "used up" after the first call.

Host/Content-Length/Connection/Transfer-Encoding in headers are always yours, never the wire's. These four are always computed by this runtime itself — Host from req.url's own hostname, Content-Length from the real body length, Connection/Transfer-Encoding from this runtime's own framing — so a value of any of the four sitting in headers (however it got there — a literal map, or req.headers/a response's own headers copied straight in, a reverse proxy's most natural shape) is simply never written to the wire; only this runtime's own value is. This matters most exactly where it's easiest to trip over: forwarding a REAL request's own req.headers into an outbound one ('headers': req.headers) already carries the ORIGINAL Host, and forwarding a REAL response's own headers back out (res.headers = upstream.headers) already carries its Content-Length/Connection — sending those through unfiltered used to produce a request or response with the SAME header name twice, which a strict server (Go's net/http, which hard-rejects a request with two Host lines) refuses outright.

Same-host requests reuse a connection instead of opening a fresh one every time (plain http://, POSIX only — an https:// request and any request on Windows still opens fresh every call, a documented, narrower-scoped v1). One small connection cache per OS thread (no locking needed: nothing else on this runtime's own thread-isolation model ever touches another thread's own outbound connections), so a loop of req.send() calls to the same host:port — the exact shape a reverse proxy's own upstream calls take — pays a real TCP handshake only on the first call, not on every one. Entirely transparent: req.send()'s own behavior and return value are unaffected either way, and a connection that's died while sitting idle (the peer closed it, a hard network error) is detected and silently replaced with a fresh one, never surfaced as a failure of the request that happened to find it stale.

Non-blocking requests: callback #

Give the literal a callback and req.send() returns immediately instead of blocking — the request runs on a background worker thread, and callback fires later, from the same main thread everything else runs on, once it completes:

void func processLater(r:http) {
    text response = r.toText()
    log(`Response: ${response} ${now()}`)
}

http req = {'url': 'https://example.com', 'callback': processLater}
req.send()
log(`Request made... ${now()}`)   // logs BEFORE processLater ever runs

callback must be func[http]:void — called with the SAME value req.send() was called on, mutated in place with the response exactly the way the blocking form already is (r.code/r.headers/r.toText() etc. all read the response once callback fires). A network failure that would otherwise throw instead leaves r.code null and r.toText()/etc. holding the failure's own message — there's no try/catch frame left to deliver a throw to by the time a background result comes back, so if r.code == null { ... } inside callback is how to tell success from failure:

void func onDone(r:http) {
    if r.code == null {
        log(`failed: ${r.toText()}`)
    } else {
        log(`ok: ${r.code}`)
    }
}

A callback-mode req survives independent of whatever scope built it — even a value constructed entirely inside a function that returns before the request finishes still fires its callback correctly later:

void func fireAndForget() {
    http {'url': 'https://example.com', 'callback': onDone}
    // fireAndForget's own local scope ends here -- the request keeps
    // going anyway, and onDone still fires once it completes.
}

Calling req.send() again on a callback-mode value queues another independent background request the same way. Linux and macOS only for now — on Windows, callback is currently not consulted at all and req.send() stays fully blocking regardless, the same staged-rollout shape audio/graphics/http itself already went through on that platform.

Shorthand: {...}.send() and http {...} #

An http literal can be sent in the same expression it's built in, without a separate req.send() statement:

http req = {'url': 'https://example.com', 'callback': processLater}.send()

This means exactly what it looks like — build the literal, then send it — not a different return value from .send() itself (.send() elsewhere still returns nothing; this is recognized specifically as a variable's own initializer).

When the response doesn't need to be read at all, drop the variable entirely — a bare http value followed directly by a literal is a complete statement, an implicit send with no name to call .send() on:

http {'url': 'https://example.com', 'callback': processLater}

(Only reachable with the leading http — a bare {...} at the start of a statement is still an ordinary block, as always.) With no callback at all, this is a fire-and-forget blocking send whose response is simply discarded — rarely useful with no callback, but not an error. Exactly like the named-variable form above, the value stays alive until its request completes (and, in callback mode, until the callback has run) even though nothing ever names it.

The url type / parseURL() #

parseURL(text):url parses an absolute URL into its components — used above to read req.url's path/query on the server side, and to build one for req.send() on the client side (a plain text field works there too; parseURL()/url exist for reading one apart, not as the only way to spell one). Throws (catch with try/catch) if the text has no :// or a non-numeric port.

url u = parseURL('https://ada:secret@example.com:8443/path?a=1&b=2#frag')
u.protocol                            // text -- 'https:' (includes the trailing colon,
                                       // matching how a browser's own URL API spells it)
u.username                            // text -- 'ada'
u.password                            // text -- 'secret'
u.hostname                            // text -- 'example.com'
u.port                                // int -- 8443 (null if the URL named none)
u.pathname                            // text -- '/path'
u.searchParams                        // map[text] -- {'a': '1', 'b': '2'}, percent-decoded
u.hash                                // text -- '#frag'

Every field is read-only — a url is built once, by parseURL(), and never mutated afterward.

Keep-alive #

A server connection stays open for another request once a response finishes, instead of closing after every single one — ordinary HTTP/1.1 semantics, nothing to opt into:

  • HTTP/1.1 requests default to keep-alive, matching every real client (browsers, curl, http.client, ...). Send Connection: close on the request to close after that one response anyway.
  • HTTP/1.0 requests default to close, unless the request itself sends Connection: keep-alive.
  • An idle connection — nothing in flight, just open and waiting to be reused — is closed automatically after about 15 seconds with no new request. A slow client still sending its OWN request (headers or body trickling in) is never affected by this; only genuinely idle time between requests counts.
  • Combines with everything else openPort() already does, including combining openPort() with graphics and WebSocket upgrades (an on upgrade connection leaves HTTP request/response handling behind entirely, so keep-alive has nothing to do there — it was never "closing" a WebSocket connection to begin with).

Nothing about handling a single request changes — on request fires once per request exactly as before, req.headers/req.toText()/etc. describe just that one request, and a fresh req value arrives for the next one on the same connection. The Connection response header is set automatically to match; a program's own req.send()/req.ok()/req.redirect() never need to think about it.

Limitations #

  • No ping/pong sent by this runtime, and no WebSocket extensions. A received ping is answered with a pong automatically; a received pong is ignored. permessage-deflate and every other WebSocket extension are unsupported (fragmentation is not an extension — see WebSocket above).
  • Linux, macOS, and Windows. Linux/macOS use plain POSIX sockets; Windows uses a real winsock2 port (see windows.md). One Windows-specific caveat, already true of every platform's Graceful shutdown story below: Windows has no real SIGTERM delivery, so the connection-drain grace period only applies to Ctrl-C there, not to however a process gets killed the SIGTERM way on Linux/macOS (e.g. taskkill without /F doesn't reach it the same way). Not available under --target=wasm32-wasi at all — WASI Preview 1 has no listening-socket support — rejected at compile time; see wasm.md.
  • Combining with graphics (render(), or an on mouseDown/.../close handler) in the same program works, but the two loops don't run side by side — a program that also opens a window blocks in the graphics event loop the whole time, which services the open port from inside itself rather than a separate thread. Practically, that means:
    • Up to ~20ms of added latency accepting a connection or reading the next byte while the window is open — the graphics loop only checks for http work on its own regular wake, the same bound already accepted for a background blob/img/aud .callback() load (see Files above). Negligible for interactive use; worth knowing if you're benchmarking raw request latency.
    • No graceful-shutdown grace period. The http-only server drains already-open connections for up to 10 seconds after Ctrl-C/SIGTERM (see Graceful shutdown below) before exiting; a combined program instead closes the window and exits immediately, with no equivalent drain window for an in-flight request.
    setTimeout/setInterval combine fine with either shape; all three (timers, an open port, and a window) are serviced from the same loop once graphics is involved.

See Graceful shutdown below (under close()/on exit) for what Ctrl-C/SIGTERM do to a running server — the port stops accepting new connections immediately, but an already-open one gets a real chance to finish first.

openSecurePort(port:int, key:blob) — TLS #

blob key = 'server.pem'   // a combined PEM file: certificate(s), then the
                           // unencrypted private key, in either order

on request(req:http) {
    req.send({'body': 'hello over TLS'})
}

openSecurePort(8443, key)

The TLS counterpart to openPort() — same listener/connection table, same single-threaded event loop, and the exact same on request/on upgrade/on socketMessage/on socketClose handler surface (a program can mix plain openPort() and TLS openSecurePort() listeners freely; a connection's own req/s behaves identically either way — nothing about reading a request or sending a response differs based on which port it arrived on). WebSocket upgrades work the same way too (wss:// on the client side).

key is one blob — read from a file the same way any other blob is (blob key = 'server.pem') — holding a PEM-encoded certificate (or a full chain, leaf certificate first) and the matching unencrypted private key, concatenated in one file, in either order. A bad port number is a silent no-op, the same "test, don't fail" convention openPort() itself uses — but a certificate/key that fails to parse, or a key that doesn't match the certificate, fails the program (via fail(), naming the real underlying problem): that is a program-authoring mistake, not a runtime condition worth testing for.

Generating a real certificate is outside this language's scope — use whatever your deployment already uses (e.g. openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes for a self-signed one, or a certificate from a real CA/ACME client for production — cat cert.pem key.pem > server.pem combines them into the one file openSecurePort expects).

Built on mbedTLS 2.x — a new system dependency, but only for a program that actually calls openSecurePort() (see setup.md); a program that only ever calls openPort() never links it, the same binary-slimming split every other optional feature in this language already gets.

Scope, beyond what Limitations above already says (all of it applies here too):

  • Server-side only. There is no TLS client in this language — openSecurePort() is the only TLS-related builtin.
  • One certificate/key pair per listening port, no SNI. A program needing per-hostname certificates calls openSecurePort() once per port instead.
  • No client-certificate / mutual TLS. This runtime never asks a connecting client for a certificate.
  • No ALPN. Every connection is plain HTTP/1.1-over-TLS — no HTTP/2 negotiation.
  • An encrypted (password-protected) private key is rejected — the key in key must be in the clear.
  • Linux and Windows, plus macOS behind the same opt-in-flag / real-hardware-verification story openPort()'s own Limitations entry above describes (FESTINA_ENABLE_MACOS_HTTP=1 — Windows needs no such flag anymore, and there is no separate TLS-specific flag on either platform, since openSecurePort() always brings openPort()'s own listener/event-loop machinery along with it, including that same graceful-shutdown gap on Windows). Not available under --target=wasm32-wasi, for the identical reason openPort() isn't there either.

Freeing and deleting #

Memory is automatic — but free and delete exist for the moments you know better than the compiler does.

T? — manually-managed values #

struct Circle { x:int y:int }

Circle? c
c.x = 1
c.y = 2
useCircle(c)
free c                     // the ONLY release this ever gets

A trailing ? right after a type — at a variable declaration or a function/thread-handler parameter — opts that one binding out of automatic memory management entirely. An ordinary Circle local is retained on alias and released at scope exit without you writing anything; a Circle? local gets none of that — nothing ever calls free on it but you. Skip that call and it leaks, on purpose: that is what "manually managed" means.

? applies to the same types free/delete already know how to release — struct, arr[T], map[T], enum, blob, img, aud, http, socket, url, regex — plus int/float/bool/text/color/font/table, where it's accepted but has no effect at all (none of those are automatically managed to begin with, so int? count = 1 behaves exactly like int count = 1).

T? is a genuinely different type from T, not a looser version of it — the same relationship amor arr[T] has to plain arr[T]. Assigning one where the other is expected, in either direction, is a compile error:

Circle? c
Circle plain = c           // error: cannot assign value of type Circle? to Circle

There's no conversion between them — but a T? declaration's own initializer may be a fresh construction of the matching plain type: a literal (regex/arr[T]/map[T]), a regex() call, or any function call at all (including one returning a plain struct):

regex? pattern = /^[a-z]+$/       // a fresh literal -- fine
arr[int]? xs = [1, 2, 3]          // a fresh array literal -- fine

Circle func makeCircle() { Circle c
c.x = 1
return c }
Circle? c = makeCircle()          // a fresh call result -- fine

Circle plain
Circle? alias = plain             // error: cannot assign value of type Circle to Circle?

This is safe specifically because the value is fresh — nothing else could already hold a reference to something just constructed right here, so there's no aliasing hazard for "no implicit decay" to guard against. Reading an existing binding of the plain type (alias above) is still rejected: that value's own lifecycle is already someone else's automatic responsibility. Once a value is bound as T?, it only ever spreads to further T? bindings the ordinary way (another declaration with no initializer, an aliasing assignment from an existing T?, or a T?-declared parameter) — the fresh-construction allowance only ever applies at the birth point.

One read-only exception: drawImage accepts an img? source. Every form of the canvas drawImage(...) and of img.drawImage(...) takes an img? where it says img, because compositing only reads the source for the duration of the call and keeps no reference to it — nothing changes hands, so there is nothing for the no-conversion rule to protect. That is what lets a layer painted by a worker thread be drawn straight onto the canvas (or onto another image) with no clip() copy in between:

img? layer = blankImage(800, 600)
Painter.postMessage(layer)        // a thread paints into it
Painter.drain()
drawImage(layer, 0, 0)            // composited directly, no copy
free layer                        // still yours to release
free/delete work on a T? value exactly as documented above — same reference-count decrement, same "an alias survives" behavior, same everything. They are simply the only release a manually-managed value ever gets: nothing else will ever call them for you. Because of that, const T? x isn't allowed (a const you could never mutate but also could never manually release would be a permanent, unavoidable leak) and ? can't appear inside another type (arr[T?], a struct field typed T?, a function's T? return type) — a manually-managed value only ever lives in a variable or parameter binding directly, so there's always exactly one place responsible for freeing it.

A manually-managed parameter can be freed, unlike an ordinary one. Freeing an ordinary parameter is a compile error — it borrows its caller's value, and the caller is the one that releases it. A T? parameter has no such caller-side release waiting to happen (nothing ever auto-manages it, on either side of a call), so freeing it is exactly as legitimate as freeing any other T? binding:

void func consume(c:Circle?) {
    log(c.x)
    free c                     // fine -- c was never "borrowed"
}

Crossing a thread boundary shares the reference, never clones it — postMessage/on message deep-clone every other value type (see Threads below), but a manually-managed one crosses by handing the raw reference straight to the other side, exactly like an ordinary alias within one thread does. Both directions work the same way:

struct Circle { x:int y:int }

Circle? c
on message(worker:thread, msg:Circle?) {
    log(msg.x)                     // 99
    log(c.x)                       // 99 -- c itself changed, proving no clone happened
}

thread Worker {
    on message(worker:thread, msg:Circle?) {
        msg.x = 99                 // mutates the SAME value the sender holds
        postMessage(msg)           // echoes the same reference back, no clone
    }
}

c.x = 1
Worker.postMessage(c)

This is sound for the identical reason T?'s own automatic-management opt-out is sound in the first place: the whole safety argument behind deep-cloning everything else (see Threads below) is that this runtime's retain/release counters are non-atomic, correct only because exactly one thread ever touches a given value's refcount. A manually-managed value's refcount is never touched by either side's automatic bookkeeping — nothing here can race on the mechanism a shared, ordinary value's clone exists specifically to avoid racing on. What's left is exactly what "manually managed" already means, now spanning two threads instead of one: don't read and write the same value from both sides at the same instant without your own synchronization, and call free/delete exactly once, from whichever side is provably done with it. Aliasing a manually-managed value never bumps its refcount, on either side of a postMessage — two bindings (a sender's and a receiver's) referencing the same value share exactly one reference, and freeing it through either one frees it for both.

free #

img spritesheet = 'spritesheet.png'
img grass = spritesheet.clip(0, 0, 31, 31)
img dirt = spritesheet.clip(32, 0, 31, 31)
free spritesheet                       // the sheet goes now, not at exit

free name releases whatever the binding holds and sets the binding to null. It works on every type:

  • struct / arr[T] / map[T] / query row / blob / img / aud / regex — a reference-count decrement, not a forced free. A value something else still points at survives until its last reference drops; freeing an array releases each element the same way, so a shared element outlives its array. An alias of a freed img/aud stays fully usable — the freed binding reads null, the alias does not, and the surface or clip goes away when the last reference does. Freeing an aud that was the last reference stops every channel still playing it. A /pattern/ literal's process-lifetime cache is immortal, so free on a binding aliasing one is a safe no-op.
  • text — the buffer is freed (a text is exclusively owned).
  • int / float / bool — nothing to release; free x is x = null.

free composes with automatic reclamation: freeing twice is a no-op, and a freed binding that scope-exit cleanup later visits is already null, which every release treats as nothing-to-do. Constants and parameters can't be freed (a parameter borrows its caller's value).

What free promises is that the binding reads null afterwards — c == null is true. It does not make a field read through that binding safe: c.x on a freed (or otherwise null) struct binding is a read through a null pointer, undefined in exactly the way an out-of-range index is (see Indexing is not bounds-checked) — it happened to print 0 on Linux and a stray heap address on macOS and Windows. Check c == null first, or don't read a struct you've freed.

delete #

map[text] example = {'data': 'some data', 'more-data': 'Some more data'}
delete example.data
delete example['more-data']

On a map, delete removes the entry — the key stops existing (forEach skips it), which setting null could never express. Deleting a missing key is a safe no-op. The key can be a computed expression: delete m[`k${i}`].

On a struct or query-row field, delete releases the value and the field reads null afterwards. On a query row it also marks the column undefined. (One inherited caveat: a struct field whose own type is struct/arr/map auto-vivifies on the next reach-through, per the zero-value rule, so it re-appears empty rather than staying null.) To remove a whole variable, that's free — delete x says so.

Saving bytes to a path #

blob, img and aud are the same shape of value — content, plus the bytes it came from — so all three save the same way.

value.save()                          // write to the path it already has
value.save(path)                      // adopt `path`, then write there
value.saveCopy(path)                  // write there, keep its own path

All three return bool: true if the write landed.

save(path) changes the value's path; saveCopy(path) doesn't. That is the whole difference. After save, everything else that acts on the value follows the new path — a blob's exists() and delete() included. After saveCopy, the value is still pointed at where it was:

blob f = 'one.txt'
f.write('original')

f.saveCopy('copy.txt')                // copy.txt written; f is still one.txt
f.write('changed')                    // ...so this goes to one.txt

f.save('two.txt')                     // two.txt written; f is now two.txt
f.delete()                            // deletes two.txt, not one.txt

saveCopy requires its path — a copy to nowhere in particular isn't a thing to ask for, and making the argument mandatory turns "I meant save()" into a compile error rather than a silent overwrite.

A value with no path can only use save(path). An img from clip(), or anything read out of a database column, has never been on disk. Calling save() on one fails the program rather than returning false — a program asking to save something to nowhere has a bug, where an unwritable directory is a condition of the filesystem and still just answers false.
img spritesheet = 'spritesheet.png'
img grass = spritesheet.clip(0, 0, 32, 32)

grass.save()                          // fails: this img has no path
grass.save('grass.png')               // fine -- and now it has one
grass.save()                          // fine from here on
grass.saveCopy('backup/grass.png')    // fine; grass.png stays its path

The path must name a file, not a directory. A directory would have to borrow a filename from somewhere, and the value that most needs saving is exactly the one with no filename to lend, so it would work only where it was least useful. Passing one answers false.

Formats survive. What gets written is the value's own encoded bytes, so an MP3 saves as an MP3 and a JPEG as a JPEG rather than being re-encoded — the same property that makes a BLOB column round-trip byte-identically. The one exception is an image you built rather than loaded: a clip() or resize() result has no source bytes, so it is encoded as PNG, which is lossless. A failed save does not adopt the path — pointing a value at a file that was never written would leave exists() answering false about a path you were just told it had.

Time #

int ms = now()                        // milliseconds since the Unix epoch
log(formatTime(ms, '%Y-%m-%d %H:%M')) // strftime, local time -> text

now() returns milliseconds since the Unix epoch — the same unit setTimeout already takes, so timing a block is just subtraction:

int started = now()
doTheWork()
log(`took ${now() - started}ms`)

Timers #

void func tick() {
    log('tick')
}

int id = setInterval(tick, 500)
setTimeout(tick, 1000)
clearInterval(id)          // clearTimeout()/clearInterval() are interchangeable

Event-driven scheduling. The program keeps running as long as a setTimeout is pending or a setInterval is uncleared — clearing every interval (or letting every timeout fire) lets it exit normally. Combines with graphics: if a program uses both, one event loop multiplexes X11 events and timer deadlines together so neither blocks the other.

Threads #

on message(worker:thread, msg:int) {
    log(`main got: ${msg}`)
}

thread worker {
    on load() {
        log('worker: ready')
    }
    on message(worker:thread, msg:int) {
        postMessage(msg * 2)
    }
}

worker.postMessage(21)

thread NAME { ... } declares an isolated background worker: its own OS thread, its own private state, and message queues to and from the main program. It starts (and runs on load(), if declared) as soon as the program starts, and idles until it either receives a message or is killed. A thread's own body can see only its own state/locals, function names, and type names — never a global variable or constant, and it cannot call an ordinary top-level function (see Isolation below).

A declaration is referred to two different ways, and the difference matters throughout this section:

  • the thread's own name (worker) — what you send to, and what the main program controls: worker.postMessage(x), worker.kill(), worker.live(cb), worker.isAlive(), worker.drain(), worker.giveRequest(r).
  • a thread value — what an on message handler receives as its first parameter, identifying whoever sent that message. It has exactly two operations: the field .main (was this sent by the main program?) and the method .reply(x). It is deliberately minimal: it can't be compared (to null or to another thread), can't be sent through postMessage, and has no text form, so log(w) and `${w}` are compile errors rather than a printed pointer.

Sending and receiving #

Every message receiver — the main program, or a specific thread — declares its own inbound type directly with one on message(worker:thread, msg:T) handler, the same shape in both places. worker identifies who sent the message and is never null: check worker.main to tell whether it came from the main program, since main itself is a real thread value here too. msg:T is the receiver's own choice of type.

There is exactly one on message at the top level for the whole program — it's what a bare postMessage(x) (inside any thread's own body) sends to. NAME.postMessage(x) sends x to a specific thread NAME, checked against that thread's own declared on message; this works both from the main program and from inside another thread's own body — threads may message each other directly, not just the main program. Sending anywhere with no matching on message declared is a compile error naming what's missing.

Since each side's msg type is declared, not inferred, sending more than one shape to the same receiver needs a pre-declared enum covering all of them, exactly like an ordinary function parameter would.

Every message is a deep, independent copy — two threads (or a thread and the main program) never share a mutable value, which is what makes running Festina code on more than one thread safe at all. Sendable types today: int, float, bool, text, color, font, blob, img, aud, url, and struct/arr[T]/map[T]/enum built recursively from any of those (a self-referencing struct/arr[T]/map[T] type is rejected at compile time — cloning it could loop forever on a genuinely cyclic value). func, http/socket, regex, table and thread itself never will be, as an ordinary postMessage argument (see Limitations below) — a live http request is a separate matter, handed off (not cloned, not sent through postMessage at all) via NAME.giveRequest(r), see Live connection hand-off below. A manually-managed (T?) value is the one exception to the deep copy — see T? — manually-managed values above, which shares the reference across the boundary instead of cloning it.

Request/response: .reply() and .callback() #

t.reply(x) sends x straight back to whoever sent the message currently being handled. It bypasses on message on the receiving end entirely (that never fires for a reply) and instead runs whichever .callback(fn) the original sender attached:

thread worker {
    on message(worker:thread, msg:int) {
        worker.reply(msg * 2)      // answers THIS message's own sender
    }
}
void func onDoubled(result:int) { log(result) }
worker.postMessage(21).callback(onDoubled)   // logs 42

A receiver's reply type isn't declared separately — it's fixed by the first t.reply(...) call found anywhere in that receiver's own body, and every later one is checked against it the same way an ordinary parameter type is (an enum works here too, for more than one reply shape). Once a receiver has a reply type, every .postMessage(x) call site targeting it — the bare form included, for a worker sending to main — must chain .callback(fn) (with fn:func[ReplyType]:void), or it's a compile error: nothing would ever be able to receive a reply that receiver might send. Chaining .callback(fn) onto a target that never calls .reply() at all is equally a compile error, for the same reason in reverse.

This works in either direction — main replying to a worker, or a worker replying to main or to another worker. fn runs on MAIN's own OS thread whenever the original send was addressed to main. When main itself is the sender (NAME.postMessage(x).callback(fn) from main's own top-level code), that was always true — main is both the sender and the one draining the reply. When a WORKER sends bare (postMessage(x).callback(fn), always addressed to main), fn is marshaled onto main through the same background mechanism blob/img/aud's own .callback() already uses, rather than running inline on the worker's own thread — so fn is never running concurrently with main's own top-level code or any other main-dispatched callback, regardless of which thread sent the original message. fn runs on the SENDING worker's own OS thread when one worker messages another directly — that send never touches main at all, so there is no main-thread hop to marshal through.

Three details worth knowing:

  • Reply at most once per message. The first .reply() consumes the one pending callback the sender registered; a second reply for the same message has nothing left to answer and is discarded.
  • A thread value outliving its own dispatch replies to nothing. Stashing worker in a struct field, an arr[thread] or a map[thread] and calling .reply() on it later — from a timer, say, or a different handler — is legal but does nothing: the message it would have answered is long finished.
  • kill() drops pending callbacks. Killing a thread discards any .callback(fn) still waiting on a reply from it, and any the thread itself was waiting on; those fns simply never run.
fn's own body is ordinary code, not thread-isolated — the one remaining case where that's a real responsibility, not just a detail, is a worker messaging another worker directly. fn is a plain top-level func, so it's free to read/write top-level variables; unlike a thread's own on message (which the compiler blocks from touching top-level state at all), nothing stops fn from doing so here. For a bare send (or any send addressed to main), that's safe by construction — fn only ever runs from main's own event loop, one callback at a time. For one worker messaging ANOTHER worker, fn runs back on the sending worker's OS thread — genuinely concurrently with main and every other thread. Two different callbacks touching the same top-level variable from two different worker threads is a real data race; keep an fn registered this way limited to state only that one worker's own replies ever touch, or relay the result onward through another postMessage (itself always safe — every queue this runtime uses is its own plain mutex-protected structure) rather than writing to shared state directly.

Thread-private state #

thread counter {
    int total = 0
    on message(worker:thread, msg:int) {
        total = total + msg
        postMessage(total)
    }
}

A variable declared directly in a thread's own body (int total = 0 above) is visible to every handler in that one thread and invisible everywhere else, including other threads and the main program — it persists across messages the same way a global would, just scoped to this one thread.

Thread-private functions #

A func declared directly in a thread's own body (a sibling of its state vars/on load/on message/on exit) is callable only from that one thread's own handlers and other private funcs, with direct read/write access to that thread's own state:

thread counter {
    int total = 0
    void func addToTotal(x:int) {
        total = total + x
    }
    on message(worker:thread, msg:int) {
        addToTotal(msg)
        postMessage(total)
    }
}

Two private funcs may call each other regardless of which one is declared first (their names are all known before any body is checked, same as top-level functions). A private func may also postMessage like a handler can — both the bare form (to main) and NAME.postMessage(x) (to another thread). An ordinary top-level func remains completely uncallable from inside a thread body — declare the helper directly inside the thread instead if it only needs to be called from there. A private func has no first-class value form (no func[...]:... reference to it by bare name) — it may only ever be called, which also means it can't be handed to .callback(fn); use a top-level func there. Each pool instance (below) gets its own independent copy of every private func, closing over that ONE instance's own state, exactly like its handlers already do.

Lifecycle: kill(), live(), isAlive(), drain() #

log(worker.isAlive())        // true
worker.kill()                 // blocks until the thread has stopped
log(worker.isAlive())        // false
worker.live(void (ok:bool) => log(ok))   // respawns it; runs on load() again
log(worker.isAlive())        // true
  • NAME.kill() stops the thread (running on exit(code:int) first, if declared) and blocks until it has actually stopped. code is always 0 here — a thread's own kill() carries no exit code of its own to pass through (unlike the main program's own on exit(code:int), which does — see Graceful shutdown). Anything still queued for the thread is discarded rather than drained first, and any pending .callback(fn) waiting on it is dropped.
  • NAME.live(callback) respawns a killed thread and calls callback(true) once it's running again.
  • NAME.isAlive() reads whether the thread is currently running.
  • NAME.drain() blocks until the thread has finished processing everything already queued for it — the deliberate opposite of kill()'s own discard-don't-wait choice. Unlike kill(), the thread keeps running afterward (still alive, still accepting new messages); this only waits, it never stops anything. A thread that isn't currently alive has nothing to drain and this returns immediately.
thread writer {
    DatabaseURL = 'writes.sqlite'
    on message(caller:thread, msg:int) {
        sqlite('INSERT INTO Log (n) VALUES (?)', [msg])
    }
}

on close() {
    writer.postMessage(finalValue)
    writer.drain()   // blocks until the INSERT above has actually run
}

This is what makes a thread with its own DatabaseURL safe to use for work that must survive the program exiting: without an explicit drain(), a message posted right before close() (or a window's own close button, or a SIGINT/SIGTERM-driven graceful shutdown) races the thread being killed during exit's own teardown — the send might land, or the thread might be stopped first with that message still sitting unprocessed in its queue, discarded exactly like kill()'s own documented behavior above. on close()/on exit(code:int) are the natural place to call it: both already run to completion before any teardown begins, so a postMessage(x) immediately followed by drain() there is a reliable "fire this off and be sure it landed before we exit" pattern.

drain() is about the thread's own side effects, not round-trip completion. It returns once the thread has processed everything — including any .reply() it made along the way — but a reply is delivered to your .callback(fn) by the main program's own event loop, which only runs once top-level code returns. So after worker.postMessage(x).callback(fn); worker.drain(), the next line always runs before fn does, never after. It also works on a thread with its own HTTP context, whose worker loop polls rather than blocks — a drain there can take up to one poll interval (~20ms) longer to notice an idle queue.

All four of these are callable only from the main program — a thread may message another thread, but may not control its lifecycle.

If the main program exits (including via close(code) or a SIGINT/SIGTERM-driven graceful shutdown), every still-alive thread is killed first (each thread's own on exit(code:int), if declared, runs with code 0, the identical kill() behavior above — the main program's own real exit code isn't threaded through), so a declared thread never survives as an orphaned process.

Isolation: what a thread body may and may not do #

A thread's body may call log/fail, string/array/map/struct/enum operations, Math, time functions, blankImage() plus every img-method call — drawing, clip/resize, pixel reads, and the image's own transform/state stack, clears and drawImage (each touches only that one private image, never shared state) — regex()/mkdir()/ls(), and exec().

It may not call any canvas/window builtin (drawRect, render, saveCanvas, ...) or setTimeout/setInterval, and it may not call any ordinary top-level func declared outside the thread — declare a func directly inside the thread instead (see Thread-private functions above).

Two builtins are allowed conditionally:

  • sqlite() — only for a thread that declared its own DatabaseURL, see A thread's own database below.
  • openPort()/closePort()/openSecurePort() — only for a thread that has already declared its own on request/on upgrade/on socketMessage/on socketClose, see A thread's own HTTP context below.
The non-blocking HTTP client form (req.send() with a callback field) is unavailable inside a thread body — its callback always runs on the main program's own OS thread, regardless of which thread dispatched the request, so a thread handing it a closure over its own private state would be a real cross-thread violation. (exec() has no such hazard any more — its own non-blocking exec(args, callback) form was removed for exactly this reason, so only the always-safe blocking exec(args) remains, usable freely from any thread body.)

A thread's own database: DatabaseURL #

A thread's own first statement may be DatabaseURL = '<literal>' — a plain string literal only, unlike the main program's own DatabaseURL (which may be any text expression):

thread logger {
    DatabaseURL = './logs.sqlite'
    table LogEntry { message:text }
    on message(worker:thread, msg:text) {
        sqlite('INSERT INTO LogEntry (message) VALUES (?)', [msg])
    }
}
logger.postMessage('started up')

A thread with its own DatabaseURL gets its own private sqlite handle — never shared with the main program or any other thread — and may call sqlite(); every table declared anywhere in the program is synced against it, the same as the main program's own database. A thread that did not declare its own DatabaseURL may not call sqlite() at all — a clear compile error naming the fix. Two contexts (a thread and the main program, or two threads) may never resolve to the same literal database file — checked at compile time across the whole program, including the main program's own default ('festina.sqlite', when it declares no DatabaseURL of its own); a non-literal main DatabaseURL (an expression the compiler can't prove anything about) skips this check rather than guessing.

Thread pools: thread NAME[N] #

thread NAME[N] { ... } declares N fully independent instances of the same body — each its own OS thread, its own private state, its own inbound queue:

thread pool[4] {
    on message(worker:thread, msg:int) {
        postMessage(msg * 2)
    }
}
pool[0].postMessage(1)
pool[3].postMessage(2)

A pool instance is addressed with NAME[i] (i any int expression, not just a literal) everywhere a singleton thread's own bare NAME would be used — pool[i].postMessage(x)/.kill()/.live(callback)/.isAlive()/.drain()/.giveRequest(r) all work identically to the singleton form, just per-instance. An out-of-range index is a silent no-op (this language's own established "test, don't fail" convention, the same one NAME.isAlive() itself already follows) — pool[99].kill() neither crashes nor raises, it simply does nothing, pool[99].isAlive() reads false, and pool[99].postMessage(x) sends nothing (its argument isn't even evaluated, and a chained .callback(fn) is never registered, so nothing is left dangling). The bare pool name on its own (pool.kill(), with no index) is a compile error naming the fix — a pool must always be indexed. There is no pool.length — the size is whatever N the declaration itself used, known at every call site already.

pool.postMessage(x) — no index — routes to whichever instance is idle right now:

thread pool[4] {
    on message(worker:thread, msg:Job) {
        process(msg)
    }
}
pool.postMessage(job)      // whichever of the 4 instances is free

An instance is "idle" the moment it has nothing queued and isn't mid-handler, from the moment it's spawned until whatever it's given next finishes — the same window drain() already tracks. If every instance is currently busy, this falls back to plain round-robin rather than waiting for one to free up: pool.postMessage(x) never blocks its caller, matching every other postMessage call in this language. Selecting is a read, not a reservation, so two callers racing to send at the same moment can occasionally both land on the same instance while another sits idle a moment longer — harmless, and the price of never stalling. Mixing this with indexed pool[i].postMessage(x) on the same pool is fine; both feed the identical queues, so routing stays correct either way. .callback(fn) chains onto the bare form exactly as it does on an indexed one — the reply comes back from whichever instance actually handled it.

pool.giveRequest(r) — no index — same auto-selection:

thread pool[3] {
    on request(req:http) {
        req.send({'code': 200, 'body': 'handled'})
    }
}
on request(req:http?) {
    pool.giveRequest(req)     // whichever of the 3 instances is free
}

giveRequest is the other exception to "a pool must always be indexed" — it reaches the identical idle-or-round-robin selection postMessage uses above, for the identical reason: handing off a live connection to "whichever instance is free" is exactly as sensible as routing a message to one. Every OTHER pool method still requires an index — pool.kill()/.live()/.isAlive()/.drain() are each genuinely about one specific instance's own lifecycle, where "whichever one" has no sensible meaning; only postMessage and giveRequest get to pick.

on request use NAME — shorthand for the hand-off pattern above. NAME can be a singleton thread or a pool:

thread router {
    on request(req:http) { req.send({'code': 200, 'body': 'ok'}) }
}
on request use router
thread pool[4] {
    on request(req:http) { req.send({'code': 200, 'body': 'ok'}) }
}
on request use pool

Both desugar, at parse time, to exactly:

on request(req:http?) {
    NAME.giveRequest(req)
}

— a manually-managed http? parameter and a single giveRequest call, nothing more. use isn't a reserved word anywhere else; it's only recognized directly after on request, the same way DatabaseURL is only recognized by name inside a thread body. Because it's pure sugar, everything above about indexed vs. bare giveRequest still applies — on request use pool gets the auto-selecting bare form; write the handler out by hand with pool[i].giveRequest(req) if a specific instance is what's wanted instead.

N is a compile-time literal because the body is compiled once per instance — each of the N instances gets its own independently named copy of every handler, private func and state variable. That's what makes the instances genuinely independent with no runtime indirection, but it does mean a pool costs N copies of its body's generated code (a ~12-line body measures around 136 lines of LLVM IR per instance), so a large N over a large body is a real compiled-size decision rather than a free one.

thread NAME[] { ... } — empty brackets — sizes the pool for you. N becomes os.cpu_count() (read on the machine compiling the program — the resulting count is an ordinary literal baked into the binary, not re-measured wherever it later runs) minus every other thread the program declares, floored at 1:

thread logger { on load() { } }   // 1 thread
thread pool[] { ... }             // gets cpu_count() - 1 instances

An ordinary singleton (thread NAME { ... }, no brackets at all) always counts as 1; an explicit thread NAME[N] { ... } counts as N. Two auto-sized pools in the same program don't split the remaining budget between them — each is sized independently against the same fixed total, so both get the same count. Once resolved, an auto-sized pool is in every other respect an ordinary thread NAME[N] { ... } — same indexing, same out-of-range-is-a-no-op behavior, same per-instance compiled-size cost above.

A pool may not declare its own DatabaseURL. Every instance in a pool runs the identical body, so a DatabaseURL = '<literal>' there would be the SAME literal path for all N of them — unlike an ordinary singleton thread's own DatabaseURL (always private to that one thread), N pool instances would each open their own independent sqlite connection into the identical file, concurrently, with no coordination between them. This is a compile error naming the fix; give each instance a genuinely distinct database with an ordinary (non-pool) thread declared per instance instead, or have pool workers message a single dedicated database thread rather than querying sqlite() directly from inside the pool.

A thread's own HTTP context #

A thread may declare on request(req:http)/on upgrade(s:socket)/on socketMessage(s:socket, msg:blob)/on socketClose(s:socket) — the identical four handlers/signatures the main program's own top-level HTTP/WebSocket support already uses (see HTTP and WebSocket servers above) — and, once it has declared at least one of them, call openPort()/closePort()/openSecurePort() too:

thread server {
    on load() {
        openPort(8080)
    }
    on request(req:http) {
        req.send({'code': 200, 'body': 'hello from a thread'})
    }
}

This gives the thread its own fully private connection table and listener set — never shared with the main program's own HTTP context, or with any other thread's — so a program can serve traffic on more than one port, from more than one OS thread, with no coordination needed between them. A request accepted on the main program's own port is never routed to a thread's on request and vice versa; two listeners on different ports (one on main, one on a thread, or two different threads) accept and respond to real, concurrent traffic completely independently. The gate on openPort()/openSecurePort() — declaring a handler first — exists because that declaration is what gives this thread's own message loop the ability to actually service a listener at all: a thread that only ever declares state/on load/on message/on exit blocks on its own inbound queue exactly as before, never polling for a connection even if one were somehow already open.

A thread that declares an HTTP-shaped handler but never calls openPort() of its own still gets this private, receive-only context — it simply never accepts a connection on its own; a live connection still reaches it whenever the main program hands one off directly (see Live connection hand-off below), or via on message's own inter-thread messaging. postMessage (bare or named) works from inside any of these four handlers exactly like it does from on load/on message/on exit or a private func.

The http client form — req.send() with zero arguments — also works from inside a thread body (it touches no shared connection-table state at all, just a fresh outbound socket), including targeting the main program's own port or another thread's; it must never target its own listener from inside that same thread's own handler/on load, though — a thread has only one OS thread servicing both its accept loop and any blocking call it makes, so a self-directed request would simply wait forever for a response nothing is left running to send.

openPort()/openSecurePort() don't check whether some other context already listens on the same port — two contexts (main and a thread, or two threads, including two instances of the same pool) racing to bind the identical port number fails exactly the way it would outside this language entirely (the OS refuses the second bind); give each its own port.

Live connection hand-off: NAME.giveRequest(r) #

The main program, having accepted a live request on its own port, may hand it directly to a thread — that thread's own on request fires for it, on the connection's own live socket, exactly as if THAT thread had accepted it itself:

thread worker {
    on request(req:http) {
        req.send({'code': 200, 'body': 'handled by worker'})
    }
}

on request(req:http?) {
    worker.giveRequest(req)
}

openPort(8080)

giveRequest is legal only from the main program (like kill()/live()/isAlive(), a thread may not call it — including on itself or another thread), and only onto a thread that has already declared its own on request. The argument must be a manually-managed http? value — main's own top-level on request handler must declare its req parameter with the ? suffix (see T? — manually-managed values above) for this to be legal at all; an ordinary, auto-managed req:http is rejected outright, since main's own end-of-handler cleanup would still release it out from under the thread it was just handed to. There is no compile-time check that main's own code never touches r again after handing it off — the same accepted-risk contract T? itself already carries: once handed off, the connection belongs entirely to the receiving thread, and reading or writing r afterward is undefined. In exchange, this costs nothing to make safe at the value level: a manually-managed value was never auto-retained or auto-released to begin with, so there's no automatic cleanup left to race against the receiving thread's own use.

A request that arrives with nothing in its own body ever calling ok()/redirect()/send()/upgrade() still gets the same forgiving default (200, empty body) whether it was answered directly or handed off — a hand-off doesn't change that behavior. A thread receiving a handed-off request needs no openPort() of its own at all — the receive-only context above is exactly what makes this useful even for a thread that never listens on any port itself, e.g. a dedicated worker that only ever handles requests main routes to it.

First-cut scope, documented, not silently absent:

  • Plain (non-TLS) connections only. A request accepted on an openSecurePort() listener cannot be handed off yet — giveRequest is a silent no-op for one (nothing crashes; the connection simply stays with main, unresponded, until its own on request returns and gets the same default-200 fallback above).
  • A connection already answered, or already upgraded to a WebSocket, cannot be handed off either — both are silent no-ops for the identical "test, don't fail" reason.
  • Never hand a thread a request whose eventual answer depends on that same thread's own blocking client call to the connection that triggered it. A thread has only one OS thread servicing both its own accept loop and any blocking req.send() it makes (see A thread's own HTTP context above) — if a thread's own client request happens to be routed BACK to itself (directly, or indirectly through main handing off every request main receives to that one thread, including ones that thread's own code triggered), nothing is left running to ever answer it, and the call waits forever. Route a driving/verifying client call through a different thread than the one receiving the hand-off.

Audio #

aud music = 'music.wav'               // WAV (16-bit PCM) or MP3
int ch = music.play()                 // once  -> the channel it played on
music.playLoop()                      // until stopped -> also returns one
music.isPlaying()                     // true the instant play() returns
music.stop()                          // silence this clip, everywhere

stopAudioPlayer(ch)                   // stop one channel
stopAudioPlayer()                     // stop every channel
isAudioPlayerPlaying(ch)              // true while THAT CHANNEL plays anything

A path declares the clip, the same way blob, color, font and img are each written as the text that reads best. It's a real load, not a compile-time resolution, so the path may be any text expression (aud hit = soundDir + 'hit.wav'). save()/saveCopy() write a clip back out; see Saving bytes to a path.

WAV (16-bit PCM) and MP3. The format is sniffed from the file's contents, not its extension — a clip out of a database column has no extension, and an extension was never evidence of anything anyway. Anything else (a compressed WAV, 8/24/32-bit PCM, Ogg, FLAC) fails at load with a message naming both supported formats. Plays through a real ALSA output device on a background thread, so playback doesn't block the rest of the program.

Stopping a sound #

There are two questions, and they have two answers.

stop() silences this clip everywhere. One clip can be playing on several channels at once, so this stops all of them. That is what you want for a looping engine hum, a music bed or a dialogue line — anything where "this sound should not be audible any more" is the whole thought.
stopAudioPlayer(n) stops one channel. That is what you want when three gunshots are overlapping and only one of them should end.

Which channel? The one play() handed back:

aud engine = 'engine.wav'
int hum = engine.playLoop()    // the pool picked a channel; now you know it
// ...later...
stopAudioPlayer(hum)           // stop exactly that one

play() and playLoop() both return the channel they used, or -1 if nothing played (which happens only when every channel is reserved) — so a channel the pool picks on its own is just as addressable afterward as one you name by hand. isPlaying() is clip-wide, like stop(): "is this sound audible anywhere" and "silence it everywhere" are one question asked two ways. For the other question — "is this specific channel still playing anything" — there's isAudioPlayerPlaying(n):

int hum = engine.playLoop()
// ...later, with no reference to `engine` in scope anymore...
if isAudioPlayerPlaying(hum) { stopAudioPlayer(hum) }

It answers about the channel, not the clip — if a different clip has since taken that channel over (play(n)/playLoop(n), or automatic stealing), it reports on whatever's playing there now, which engine.isPlaying() couldn't do once engine itself said false.

Overlapping sounds #

play() while a sound is already playing does not cut it off. Sound goes out through a pool of channels — so a footstep, a gunshot or a coin pickup firing in rapid succession layer instead of interrupting each other, which is what a game actually needs:

aud coin = 'coin.wav'
coin.play()   // three overlapping copies, not one restarted three times
coin.play()
coin.play()

The clip's audio is decoded once, at the declaration; a channel costs a thread and a device handle, never another copy of the samples.

setMaxAudioPlayers(4)          // channels the pool may assign on its own
log(maxAudioPlayers())         // -> 4, i.e. what was actually applied

setMaxAudioPlayers is clamped into [1, 64] rather than rejected. When every unreserved channel in the pool is busy, the oldest is stolen. Something has to give at the limit, and the sound that has been playing longest is closest to finishing anyway — dropping the new play instead would silence a rapid-fire effect at exactly the moment it fires fastest. setMaxAudioPlayers(1) restricts the pool to a single channel, so every play() restarts playback from the beginning on that one channel.

Channels and looping #

Channels are process-global and numbered from 0, not per-clip — so two different clips can share one, which is what makes handing a music channel from one track to another expressible at all:

aud adventureMusic = 'adventure.wav'
aud battleMusic = 'battle.wav'

adventureMusic.playLoop(0)          // loops on channel 0, and reserves it
setInterval(changeMusic, 100000)

void func changeMusic() {
    if adventureMusic.isPlaying() {
        battleMusic.playLoop(0)     // takes channel 0 over
    } else {
        adventureMusic.playLoop(0)
    }
}

stopAudioPlayer(0)                  // stop that channel, release it
clip.play()Play once on a channel the pool picks.
clip.play(n)Play once on channel n, taking it over.
clip.playLoop()Loop on a channel the pool picks, and reserve it.
clip.playLoop(n)Loop on channel n, taking it over and reserving it.
stopAudioPlayer(n)Stop channel n and release it.
stopAudioPlayer()Stop every channel.
clip.isPlaying()True while any channel is playing that clip.
isAudioPlayerPlaying(n)True while channel n is playing anything, regardless of clip.

playLoop reserves its channel. A reserved channel is never chosen by automatic assignment and never stolen — so a looping music track cannot be evicted by an ordinary sound effect, however many are firing. Two things release it: stopAudioPlayer(n) (or a bare stopAudioPlayer()), and naming the channel explicitly in another play(n)/playLoop(n). An explicit play(n) both takes the channel over and hands it back to the pool, since a one-shot has nothing to reserve it for.

An out-of-range channel is clamped into [0, 64), the same call setMaxAudioPlayers makes — a bad channel number should not kill a running game. setMaxAudioPlayers bounds only what the pool assigns on its own; an explicitly named channel is honoured anywhere in range, so play(40) works with a pool of 10. If you reserve every channel and then fire an unnamed play(), it is dropped — there is nothing left the pool is allowed to touch, and the alternative would be breaking a reservation you asked for. isPlaying() is about the clip, not one playback of it: it is true while any channel is playing that clip. To ask about a single playback, name its channel with isAudioPlayerPlaying(n) instead.

log() / fail() / close() #

log(value)       // prints any primitive to stdout, newline-terminated
fail('message')  // prints to stderr, exits(1)
close(code)      // exits(code), running `on exit` first if declared

close(code) exits the program with the given exit code — it works in every program, with or without a window, unlike the graphics-only on close handler under Graphics above (which fires on the window's own close button and is a different thing with a similar name). If a program declares on exit(code:int) { ... }, close(code) runs it — passed the same code — before the process actually exits:

on exit(code:int) {
    log(`exiting with ${code}`)
}
log('working...')
close(1)          // prints "exiting with 1", then exits with status 1

With no on exit handler declared, close(code) just exits.

Graceful shutdown (Ctrl-C / SIGTERM) #

A program that uses openPort()/openSecurePort(), setTimeout/setInterval, or graphics stops the same clean way close(code) already does when it receives SIGINT (Ctrl-C) or SIGTERM — a declared on exit(code:int) fires (passed a conventional 128 + signal code: 130 for SIGINT, 143 for SIGTERM), then the process exits — instead of the OS's own default, abrupt, no-cleanup-at-all termination:

on exit(code:int) {
    log(`shutting down (${code})`)
}
on request(req:http) {
    req.send({'code': 200, 'body': 'hello'})
}
openPort(8080)
// Ctrl-C logs "shutting down (130)" before the process exits.

For an HTTP/WebSocket server specifically, shutdown is graceful in the way that matters: every listening port closes immediately (a new connection attempt is refused right away, not silently dropped), but connections already open are given up to 10 seconds to finish on their own before this runtime gives up on them and exits anyway — a normal request/response finishes in milliseconds, so this only ever matters for a long-lived WebSocket connection that never closes.

Only installed where it can actually take effect. A plain script with no openPort()/timers/graphics — just top-level code, or your own hand-written loop — keeps the OS's own default SIGINT/SIGTERM behavior (an immediate kill, on exit not run): there is no point in such a program's own execution where it could ever notice a shutdown request, so installing a handler there would make Ctrl-C stop working instead of merely skipping cleanup. SIGTERM is POSIX only; Windows has no real delivery of it (only SIGINT/ Ctrl-C) — killing a Windows-compiled openPort() program the way SIGTERM would on Linux/macOS force-kills it instead (no on exit, no connection-drain grace period, no 143 exit code). SIGINT is registered the same way on every platform (the CRT does raise it on Windows too), but confirming it end to end on Windows needs the child process launched with CREATE_NEW_PROCESS_GROUP for Python's Popen.send_signal(signal.SIGINT) to even reach it there — this project's test fixtures don't currently do that.

troubleshoot() — structured logging #

troubleshoot('user_login_failed', {'user_id': '7', 'reason': 'bad_password'})
// {"timestamp":"2026-08-25T16:18:47Z","level":"info","event":"user_login_failed","fields":{"user_id":"7","reason":"bad_password"}}

troubleshoot(event, fields) prints one JSON line to stdout — a timestamp (UTC, RFC3339-ish), a fixed "level":"info", event (any type, coerced to text like log()/fail()), and fields (must be map[text] — string tags, not an arbitrary value; wrap whatever you need to attach as text first). Meant to be piped into a real log aggregator rather than read by eye — every field is always present and always in the same shape, unlike log(). Both arguments are required; pass {} for fields if there's nothing to attach.

fail(message) still works exactly the way it always has — and it's still what an uncaught throw produces too. fail(message, fields) is the structured form: a JSON line to stderr instead of the plain fail: <message> line — "level":"error", key "message" rather than "event" — then exit(1), same as always:

fail('db connection lost', {'host': 'db1', 'retry': 'no'})
// {"timestamp":"2026-08-25T16:18:57Z","level":"error","message":"db connection lost","fields":{"host":"db1","retry":"no"}}

try / catch / throw #

void func risky(x:int) {
    if (x < 0) {
        throw `negative: ${x}`
    }
    log(x)
}

try {
    risky(5)
    risky(-1)
    log('unreachable')
} catch (error:text) {
    log(`caught: ${error}`)
}
log('still running')

throw <expr> raises expr, coerced to text exactly like log()/fail() (any type works — not just text). It unwinds up through however many function calls are on the way (not just a throw written directly inside the try body itself) to the nearest enclosing try/catch, binding the caught message to catch's own variable — always declared :text, since a thrown value always is one. With no enclosing try reachable at all, throw behaves exactly like fail(expr): prints to stderr and exits(1) — throw is never a riskier way to end the program than fail() already is, only a strictly more capable one. return, break, and continue all work normally from inside either a try or a catch body, and a caught catch body can itself throw again (a rethrow, or a different error entirely) to propagate out to whatever try encloses that.

A throw leaks nothing on its way out. throw unwinds by jumping directly to the catching try (not by returning normally through every call frame in between), which used to mean that a function which merely called something that eventually threw — no throw or try of its own — never ran its scope-exit cleanup, and its struct/arr/map/text/etc. locals leaked. Not any more: in a program that contains a try anywhere, every managed local is registered on a per-thread cleanup stack in the runtime as it is bound (the same stack .toStruct()/.toArr() already use for their half-built values), and a throw releases every entry above the catching try, newest first — the throwing function's own locals, every intermediate frame's, the argument temporaries each call site on the chain was holding for its callee (a literal [1, 2, 3], a template text, a call result), and a catch variable of a frame that rethrows. Measured under AddressSanitizer and Valgrind: 0 bytes leaked across every kind of local, through three frames, a loop-body local, an escaping parameter, a rethrow and a JSON parse failure two frames down. A program with no try pays nothing (its generated code is unchanged: a throw there is fail()); one with a try pays one runtime call per managed local binding plus one per scope exit — about 8 ns per binding, measured as roughly a third more on a function that binds a text, two arrays and a struct and does nothing else (2 million calls: 0.25 s to 0.33 s), and lost in the noise on anything that does real work with them.

That covers the runtime's own frames too. A .sort() comparator is ordinary Festina code and can throw; the throw jumps past the runtime's sorting frame, and the scratch buffer that frame allocated is released on the way out like anything else. The array being sorted is sorted in place, so after a comparator throws it still holds some permutation of its own elements — never freed, never corrupted — and sorts correctly if you sort it again. .forEach() allocates nothing and never had the problem. A throw from a timer or an event handler is a different case: those fire from the event loop, where no try can be live, so such a throw ends the program exactly as an uncaught one does.

Not available under --target=wasm32-wasi. wasi-libc has no setjmp/longjmp support at all — rejected at compile time; see wasm.md. A program that never writes try/catch/throw is completely unaffected there (a .toStruct()/.toArr() parse failure, for example, still behaves exactly like the documented "no enclosing try" case above — prints and exits(1) — since that was always the fallback for an uncaught throw anyway); what's actually unavailable is catching one. Every native target — Linux, macOS and Windows — has it: a try is a direct call to libc's own setjmp and a throw is libc's longjmp (earlier versions used LLVM's SjLj intrinsics, which have no AArch64 lowering — so macOS rejected try outright — and a broken x86_64 Windows one).

.toStruct() / .toArr() — parsing JSON #

struct Person { id:int  name:text  active:bool  score:float }
Person p = '{"id": 7, "name": "Ada", "active": true, "score": 9.5}'.toStruct(Person)
arr[int] xs = '[1, 2, 3, 4, 5]'.toArr(int)

text.toStruct(StructName) and text.toArr(ElementType) parse a JSON value into a real Festina value — the reverse of .toText()'s own JSON rendering (Logging and rendering above). A JSON object key matches a struct field by name, case-insensitively (the same convention a query column already matches by) — a JSON key with no matching field is silently skipped (so an API that adds fields over time doesn't break your program), and a struct field the JSON never mentioned keeps its ordinary zero value (so an optional/omitted field doesn't either). toArr's own element type is given directly, not in brackets: .toArr(int), not .toArr(arr[int]).

A struct field or toArr element type may itself be a nested struct, arr[T] or map[T], recursively — the JSON parser recurses into a nested value's own shape the exact same way .toText()'s own rendering already recurses for a nested container. A map[T] field parses the JSON object's own keys directly into the map (arbitrary keys, not matched against a known field set the way a struct's own fields are):

struct Point { x:int  y:int }
struct Line { a:Point  b:Point  label:text }
struct Scores { name:text  values:map[int] }

Line l = '{"a":{"x":1,"y":2},"b":{"x":3,"y":4},"label":"hi"}'.toStruct(Line)
arr[Point] pts = '[{"x":1,"y":2},{"x":3,"y":4}]'.toArr(Point)
arr[arr[int]] grid = '[[1,2],[3,4,5]]'.toArr(arr[int])
Scores s = '{"name":"ada","values":{"a":1,"b":2}}'.toStruct(Scores)

A self-referencing struct (see A struct can name itself above) parses to whatever depth the JSON actually has, one nested call per level actually present:

struct Node { n:int  next:Node }
Node head = '{"n":1,"next":{"n":2,"next":{"n":3}}}'.toStruct(Node)
log(head.next.next.n)   // 3

Malformed JSON, a value that doesn't match the expected shape (a string where a number was expected, an object where an array was expected, ...), or trailing data after the value all throw a descriptive text message — this is the intended pairing with try/catch above, e.g. for parsing an untrusted req.toText() body in an on request handler without a bad request taking the whole server down.

The remaining scope cut, documented not silent. A target struct's fields and toArr's own element type must eventually bottom out at int/float/bool/text once every nested struct/arr[T]/map[T] is unwrapped — a genuinely un-parseable type (img, aud, func[...], ...), anywhere in that nesting, is rejected at compile time with a clear error naming exactly what's unsupported, even when the violation is several levels deep. \u unicode string escapes are also not yet supported (raw, un-escaped non-ASCII UTF-8 bytes in a JSON string are unaffected and parse completely normally — this only affects a producer that specifically chooses to \u-escape).

A parse that fails partway through leaks nothing. A JSON value that fails to parse partway through being built — a struct whose third field turns out to be the wrong type, having already parsed the first two; an array whose fourth element fails, having already collected three; a complete value followed by trailing data — used to leak whatever was already built for that one call, the same structural class as throw's own intermediate-frame limitation above. It no longer does: every generated parsing function registers the value it is building (and each JSON key it has read but not yet freed) on a per-thread cleanup stack in the runtime, and the .toStruct()/.toArr() call site registers its own cursor, the receiver's temporary text and the finished value; a throw releases every one of them, newest first, on its way to the catching try. This is plain runtime C — no setjmp of its own — which is why JSON parsing works under --target=wasm32-wasi even though try/catch itself does not (an uncaught parse failure there simply ends the program the way any uncaught throw does). The same stack bounds how deep a self-referencing struct may nest (1000 builder levels); input deeper than that throws JSON nested too deeply instead of exhausting the C stack, the same protection an unknown field's skipped value already had. Verified under Valgrind across a flat struct, a nested struct field, an array, a map[T] field, a self-referencing struct, malformed syntax, a duplicate text key whose second value fails, trailing data after a complete value, and 2000-level nesting — 0 bytes lost and 0 invalid frees (tests/valgrind_stress/json_parse_fail_churn.f, run by scripts/valgrind_stress.sh; since 0.43 the same program also runs clean under scripts/leak_stress.sh's AddressSanitizer build — the Valgrind tier originally existed only because ASan could not instrument through the LLVM SjLj intrinsics try used to be built on, which 0.43 replaced with libc's own setjmp/longjmp).

Error format #

Compile errors are file:line:column: error: message, e.g.:

main.f:12:5: error: condition must be bool, found text

See tests/test_semantic_errors.py for the full set of categories.

Examples #

examples/ has a full set of small, runnable programs exercising everything above, including a real playable game (tic_tac_toe.f) — see the README's "See it in action" section for the index, or just:

$ bin/festina run examples/<name>.f