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 out | Compile 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.f | Compile 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 doctor | Checks 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 --fix | Same 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 update | Pulls 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 help | Prints 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:
| input | text | ascii |
|---|---|---|
| 10.4 KB | 50 ms | — |
| 20.8 KB | 201 ms | — |
| 41.6 KB | 800 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, ornull.- 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
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:
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.
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.
.lengthis 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.
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')is1for['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
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].
JSON and full-text search #
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) } }
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.
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.
clearCanvasclearRectclearCircleclearPixel
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 assetThat 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.
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
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 1024x700This 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 }
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.height | Current 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')
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.
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.
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'.
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.
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, ...). SendConnection: closeon 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 combiningopenPort()with graphics and WebSocket upgrades (anon upgradeconnection 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-deflateand 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
SIGTERMdelivery, so the connection-drain grace period only applies to Ctrl-C there, not to however a process gets killed theSIGTERMway on Linux/macOS (e.g.taskkillwithout/Fdoesn't reach it the same way). Not available under--target=wasm32-wasiat all — WASI Preview 1 has no listening-socket support — rejected at compile time; see wasm.md. -
Combining with graphics (
render(), or anon mouseDown/.../closehandler) 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/setIntervalcombine fine with either shape; all three (timers, an open port, and a window) are serviced from the same loop once graphics is involved. - 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
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 requeston upgradeon socketMessageon 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
keymust 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, sinceopenSecurePort()always bringsopenPort()'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 reasonopenPort()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 freedimg/audstays fully usable — the freed binding readsnull, the alias does not, and the surface or clip goes away when the last reference does. Freeing anaudthat was the last reference stops every channel still playing it. A/pattern/literal's process-lifetime cache is immortal, sofreeon 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 xisx = 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).
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.
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
threadvalue — what anon messagehandler 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 (tonullor to anotherthread), can't be sent throughpostMessage, and has no text form, solog(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.
int, float, bool,
text, color, font,
blob, img, aud,
url, and
structarr[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
threadvalue outliving its own dispatch replies to nothing. Stashingworkerin a struct field, anarr[thread]or amap[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; thosefns 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 (runningon exit(code:int)first, if declared) and blocks until it has actually stopped.codeis always0here — a thread's ownkill()carries no exit code of its own to pass through (unlike the main program's ownon 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 callscallback(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 ofkill()'s own discard-don't-wait choice. Unlikekill(), 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 ownDatabaseURL, see A thread's own database below.openPort()/ closePort()/ openSecurePort()— only for a thread that has already declared its ownon request/on upgrade/on socketMessage/on socketClose, see A thread's own HTTP context below.
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.
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 —giveRequestis a silent no-op for one (nothing crashes; the connection simply stays with main, unresponded, until its ownon requestreturns 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.
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.
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/,
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