The Language

Quick introduction

Flitter is a declarative tree-construction language. All values are vectors, delimited with semicolons, and all maths is element-wise. Short vectors are repeated as necessary in binary operations, i.e., (1;2;3;4) * 2 is 2;4;6;8. The null value is an empty vector and most expressions evaluate to this in the event of an error. In particular, all maths expressions involving a null will evaluate to null.

A-la Python (or Haskell), indentation represents block structuring. let expressions name constant values, everything else is largely about creating nodes to append to the implicit root node of the tree. There are no variables – the language is pure-functional.

The simplest program would be something like:

-- Hello world!

let SIZE=1280;720

!window #top size=SIZE
    !canvas size=SIZE antialias=true composite=:add
        !group font="Helvetica" font_size=100 color=sine(beat/2)
            !text point=SIZE/2 text="Hello world!"

This contains a comment, a let expression and a node creation expression. Indented expressions below this represent child nodes. Any name with a ! in front of it creates a node of that kind; the bindings following this specify attributes of the node. Nodes can also be followed by one or more #tags to add tags for readability and logging.

When the Flitter engine is run with this file, it will evaluate the code repeatedly (at an attempted 60fps) and render this to screen. Note that one explicitly specifies a window to be drawn into. The engine supports multiple windows.

flitter examples/hello.fl

A !canvas node creates a 2D drawing canvas that follows an SVG-like drawing model. !group sets styles and transforms. !text draws text, centred at point by default. sine() is a function that reproduces a sine wave ranging over [0..1] with the argument expressed in waves (turns/circumferens). beat is a global representing the current floating point beat of the beat counter. The default BPM is 120, so beat/2 is effectively a number of seconds since the counter started. colors are 3- or 4-vectors (RGB and RGBA) in the range [0..1], but the return value from sine() here is automagically extended out to a 3-vector resulting in a varying brightness of white.

true and false are synonyms for 1 and 0. Truthfulness is represented by any non-empty vector that contains something other than 0 or the empty string. Names prefixed with a : are symbols, which should be considered as opaque values (see Symbols below for details). When a string is required, such as the text attribute of !text in the example above or the composite attribute of !canvas, each element of the vector will be converted to a string as necessary and then these concatenated together, e.g., "Hello ";name;"!". Symbols return to being strings (without the leading :) in this conversion.

So the end result of this should be the text “Hello world!” pulsing white in the middle of the window. You can edit and re-save the code while the engine is running and it will reload the code on-the-fly - this is usually fast enough not to be noticeable.

The engine-supplied global values are:

  • beat - the current beat counter beat (a monotonically-increasing floating point value)

  • quantum - the beats per quantum (usually 4)

  • delta - the difference between the current value of beat and the value at the last display frame

  • time - the current frame time (derived from Python’s perf_counter() usually, though increasing by exactly 1/fps per frame in non-realtime mode)

  • clock - the current UTC time as a seconds-since-UNIX-epoch value

  • frame - the current frame number, counting from 0 and increasing by one for each rendered frame (this will increase by exactly 1 on each program execution when running in non-realtime mode)

  • fps - the current target frame-rate of the engine (the --fps option)

  • performance - a value in the range [0.5 .. 2.0] that increases fractionally if the engine has time to spare and decreases fractionally if it is missing the target frame rate; this value can be multiplied into variable loads in the code – e.g., number of things on screen – to maintain frame rate

  • realtime - a true/false value indicating whether the engine is running in realtime mode (the default) or not (with the --lockstep option)

  • run_time - the number of seconds that the engine will run for before exiting, controlled with the --runtime options.

Unicode Support

All Flitter source files must be UTF-8 encoded. All names, including symbols and node kinds and attributes, must begin with a Unicode alphabetic character or an underscore and then may contain any number and combination of underscores and Unicode alphanumeric characters, suffixed by zero or more single quote primes ('). Therefore names may contain the full range of non-Latin characters including diacritics.

All Flitter language keywords and supported render nodes and attributes use only Latin characters and are generally English words or abbreviations (using US spelling). Therefore, Flitter programs can be written using only ASCII characters, but feel free to use hieroglyphs if you wish.

Comments

Everything following a pair of sequential dash characters (--) up to the end of that line is a comment. There is no multi-line comment sequence in Flitter, use -- at the beginning of each line.

Values

All values are vectors of either floating point numbers, Unicode strings, nodes or functions (or a mix thereof). The vector implementation is optimised for vectors of numbers, particularly short vectors. There are no dedicated integer values in Flitter and so one should be careful of relying on integer numbers outside of the safe integer range of a double-precision floating point (\(-2^{53}\) .. \(2^{53}\)). Numbers may use exponential e notation and may contain underscores _ to separate groups of digits for readability.

Mathematical operators operate only on pure number vectors. Using them on anything else will return the empty vector (null). Unicode strings can be concatenated by vector composition, e.g., "Hello";" world!" - the result will be a 2-vector, but vectors are implicitly concatenated anywhere that strings are used in the language. Numbers will be turned into strings using general-purpose formatting and so "Number ";1 is also a valid string.

Named values

null

The empty vector

true

The value 1

false

The value 0

inf

The IEEE-754 floating-point infinity value

nan

The IEEE-754 floating-point “not a number” value

SI Prefixes

Flitter supports adding an SI prefix to the end of a number. This is confusing terminology, but an SI prefix is a prefix to a units suffix. Flitter does not support units, so you just end up with the SI prefix as a suffix. (Confused yet?)

The allowed SI prefixes are:

  • T\(\times 10^{12}\)

  • G\(\times 10^{9}\)

  • M\(\times 10^{6}\)

  • k\(\times 10^{3}\)

  • m\(\times 10^{-3}\)

  • u\(\times 10^{-6}\) (also µ)

  • n\(\times 10^{-9}\)

  • p\(\times 10^{-12}\)

You can suffix any number with one of these letters, e.g., 10m is the same as 0.01. They are primarily useful for avoiding difficult-to-read long sequences of zeros, for example, when specifying the brightness of point and spot lights:

!light position=0 color=1M

Unicode Strings

All Flitter source code files must be UTF-8 encoded and all Unicode characters are permitted in string values. In-line strings may be denoted with straight single (') or double (") quotes. Strings may be broken across multiple lines by enclosing them in triple-single (''') or triple-double (""") quotes. The usual range of backslash escape sequences are supported within strings, including Unicode hexadecimal escapes with \u.

Anywhere in the rendering engine where strings values are expected, vectors will be converted into a single string value. This will be done by concatenating each element of the vector after converting any non-string values into strings. Numeric values will be converted into their “general” representation (integer numbers will not have a decimal component, very large numbers will use e notation) with a maximum of 9 decimal places. Functions will be converted into the function name. Nodes will be converted into their kind, without the node literal exclamation character !, tags, attributes or children. Symbols will be converted into their name, without the : character.

Symbols

Symbols are names that can be used as values. They are used in various places in the rendering engine for specifying enumerated values. For example, the composite attribute of !canvas specifies the blend function to use when drawing:

!window
    !canvas composite=:add
        

They are also commonly used when constructing state key vectors or seed vectors (see State below and Pseudo-random sources). Although strings may be used for the same purpose, symbols are more readable in the code and are specifically optimised in the engine for faster execution.

Symbols should be considered to be opaque values. They are actually deterministically converted to very large negative numbers in the parser (below \(-10^{292}\)). Whenever a name value is expected by the engine, numbers will be looked-up in the symbol table to see if they match a known symbol. If so, the number will be converted into the matching name. This should be treated as an implementation detail and not relied upon in code. In particular, because they are really just numbers, symbols can be used in mathematical operations. They shouldn’t be, and are deliberately massive to hopefully cause bad things to happen if they are.

While a clash between a symbol’s number and an actual number being used in a Flitter program is possible, it is very unlikely and wouldn’t cause any problems unless that number is converted into a string – in which case, the number will become the symbol name.

Warning

While symbols and string values are interchangeable in situations where a string is accepted (such as the composite attribute in the example above), they are not interchangeable as state mapping keys.

If this documentation refers to a specific state key as using a symbol, then that symbol must be used when retrieving that state value. Similarly, if a state key or prefix given in the program as a state attribute contains a symbol, then that symbol must be used to retrieve the state value. If a string has been used, then a symbol cannot be used to retrieve the value.

Time codes

In addition to normal literal numbers, as described above, Flitter supports literal time codes, which are given as a sequence of hours, minutes and seconds separated with colon characters (:), with the hours being optional and the seconds having an optional decimal fraction. For example:

!video filename='test.mp4' position=02:05.3

Time codes are converted by the parser into a single-item numeric vector representing the total number of seconds (125.3 in the example above). The hours component may be an arbitrarily large integer value, the minutes and seconds must be in the range [0,60). Time codes may not be combined with exponents or SI prefixes, and do not support _ separators.

Nodes

The purpose of any Flitter program is to construct a render tree. Individual literal nodes are represented by an exclamation mark followed by a name, e.g., !window. Nodes can be tagged with additional arbitrary names that aid in readability. A hash character followed by a name will tag the preceding node (or nodes) with that name, e.g., #top. A name followed by an equals character will set that attribute on the preceding node. The resulting value in both of these instances is the tagged/attributed node.

For example:

!window #top size=1920;1080 vsync=true title="Hello world!"

constructs a literal !window node, adds the #top tag to it, then sets the size, vsync and title attributes. Note that everything here is an expression/operator and it is equivalent to:

(((((!window) #top) size= 1920;1080) vsync= true) title= "Hello world!")

!window, 1920;1080, true and "Hello world!" are literal vector values, #top is a unary postfix operator, and size=, vsync= and title= are binary operators.

Setting an attribute to null will remove that attribute from the node if it is already present, or do nothing otherwise.

Block indentation below a node is a binary operation that evaluates the indented expressions as a sequence and then appends each node in the result vector to the node above.

For example:

!window #top size=1280;720 vsync=true title="Hello world!"
    !canvas
        !rect point=0;0 size=1280;720
        !fill color=1;0;0

constructs the !rect and !fill nodes and composes them into a vector, then constructs a !canvas node and appends these to it. The resulting tree is appended to a !window node, which is the final value of this expression. Running this program as-is will result in a red window with the title “Hello world!”.

Vector Node Operations

As nodes are values, and thus vectors, tag unary-postfix operations, attribute-set binary operations and append operations are able to operate on a vector of nodes simultaneously. For example:

let points=10;20;15;25;20;30;25;35

!path
    !move_to point=0;0
    ((!line_to point=x;y) for x;y in points) #segment

This is a strange, but perfectly legal, way of doing the equivalent (and more readable):

let points=10;20;15;25;20;30;25;35

!path
    !move_to point=0;0
    for x;y in points
        !line_to #segment point=x;y

One could also write:

(!group translate=x*5;x*10) for x in ..10
    !ellipse radius=5

to construct a 10-item vector of !group nodes and then simultaneously append an !ellipse node to each one.

Ranges

start..stop|step

A range creates a vector beginning at start and incrementing (or decrementing if negative) by step until the value is equal to or passes stop - the last value is not included in the range, i.e., it is a half-open range.

start and |step may be omitted, in which case the vector will begin at 0 and increment by 1. Therefore:

  • ..10 evaluates to the vector 0;1;2;3;4;5;6;7;8;9

  • 1..21|5 evaluates to the vector 1;6;11;16

  • 1..0|-0.1 evaluates to the vector 1;0.9;0.8;0.7;0.6;0.5;0.4;0.3;0.2;0.1

Ranges are not lazy as they are in Python, so ..1000000 will create a vector with 1 million items.

Indexing

Specific elements, or ranges of elements, may be extracted from a multi-element vector using indexing. Indexing uses the familiar syntax:

src[index]

As all values in Flitter are vectors, index may itself be a multi-element vector.

The rules for indexing an n-element vector, src, are:

  • If either index or src is null, the operation will evaluate to null

  • index must be an entirely numeric vector or it will be treated as null

  • index is considered element-at-a-time

  • Non-integer indices are floor-ed to the next integer value down

  • Indices are used, modulo the length of src, to select an item from src

  • All selected elements are composed together into a new vector

Ranges are a convenient way to create indices for slicing a vector, e.g., xs[..5] will extract the first 5 elements of xs (assuming xs has 5 or more elements). Indices do not need to be contiguous or in any specific order. It is perfectly valid to use x[1;6;0] to extract the 2nd, 7th and 1st items, in that order.

As indices are used modulo the length of the source vector, indices past the end of the vector will wrap around to the beginning and negative indices will select items backwards from the end. This means that xs[-1] can be used to pick out the last element of xs. It also means that all index values of a single element vector will return that element, and that a value can be easily expanded out to a long vector with a range. For example, the following will set xs to the number 5 repeated 100 times:

let xs = 5[..100]

Indexing of Unicode string vectors extracts the n-th element of the vector, not the n-th character of the string. Therefore ("Hello ";"world!")[0] is "Hello ". See the text functions for a mechanism for extracting individual characters or ranges of characters.

Operators

Flitter supports the usual range of operators, with a lean towards the syntax and semantics of Python.

Mathematical operators

The binary vector mathematical operators are:

  • x + y - addition

  • x - y - subtraction

  • x / y - division

  • x * y - multiplication

  • x // y - floor division (i.e, divide and round down)

  • x % y - modulo (pairs with floor division)

  • x ** y - raise x to the power y

All of these operations return a vector with the same length as the longer of x and y. The shorter vector is repeated as necessary. Note that, as well as this meaning (1;2;3;4) + 1 is equal to 2;3;4;5, it also means that (1;2;3;4) + (1;-1) is equal to 2;1;4;3. The operators are left-associative, with ** having the highest precedence, then /, *, // and % at the next level, and finally + and -.

Note that the vector composition operator ; has a very low precedence and so composed vectors will generally have to be wrapped in parentheses when used with operators:

x;y + 1 -- is equivalent to: x;(y+1)
(x;y)+1 -- is equivalent to: (x+1);(y+1)

The % modulo operator follows the Python convention rather than C style. It is best understood as the remainder of a // floor division:

x % y == x - x // y * y

This means that % will provide a seamless repeating sequence around zero:

(-10..10) // 5 == (-2;-2;-2;-2;-2;-1;-1;-1;-1;-1; 0; 0; 0; 0; 0; 1; 1; 1; 1; 1)
(-10..10) % 5  == ( 0; 1; 2; 3; 4; 0; 1; 2; 3; 4; 0; 1; 2; 3; 4; 0; 1; 2; 3; 4)

The unary mathematical operators are:

  • - x - negate

  • + x - identity

They sit between ** and /, *, // and % in precedence and so:

-x ** y == -(x ** y)
-x * y == (-x) * y

All of the mathematical operators return null if either x or y is an empty or non-numeric vector. Therefore, unary +x will return x iff x is numeric and null otherwise.

Comparison operators

The binary vector comparison operators are:

  • x == y - equality

  • x != y - inequality

  • x < y - less than

  • x > y - greater than

  • x <= y - less than or equal

  • x >= y - greater than or equal

Complete vectors are compared and a single truth value returned. The vectors x and y are equal iff they are of the same length and each element of x is identical to the corresponding element of y. Logical true is indicated by a result of 1 and false with a result of 0. The built-in names true and false evaluate to the same values.

The lesser/greater comparison operators compare vectors an element at a time until an element of x is less/greater than the corresponding element in y. If one vector runs out of elements before the other then the shorter one is lesser. If an element pair cannot be compared because they are not of the same type, e.g., an attempt to compare a Unicode string with a number, then the result of the comparison is the null vector (which will be interpreted as false in logical expressions).

Additionally, a vector can be tested to see if it contains a sub-vector with the contains operator:

  • x in y - returns true if any sub-vector of y equals x

This operator will always return true if x is the null vector, regardless of what y is.

Logical operators

Flitter also supports the usual short-cutting logical operators:

  • x or y - short-circuiting or: returns x if x is true, y otherwise

  • x and y - short-circuiting and: returns x if x is false, y otherwise

In addition, it provides a non-shortcutting exclusive-or operator:

  • x xor y - exclusive or: returns y if x is false, x if y is false, false otherwise

And the usual logical inverse operator:

  • not x - logical inverse: returns false if x is true, true otherwise

For the purposes of all of these operators, a vector is considered to false if it is zero-length (i.e., null) or each element is either 0 or an empty Unicode string. All other vectors are considered to be true.

Let Expressions

Values may be bound to names with the let keyword. It is followed by one or more name=expressions. The expressions are evaluated from left to right, with each name being bound to the resulting value and added into the scope of the current sequence.

Lets may be used at the top-level in a Flitter script or anywhere within a block-structured sequence, i.e., within append, function, conditional and loop bodies. Each of these sequences represents a new let scope and names that are re-bound will hide the same name in an outer scope. The outer scope bindings will be in place while evaluating the values of inner scope lets.

For example:

let x=10

if x > 5
    let x=x*2
    !foo x=x

!bar x=x

will evaluate to the two top-level nodes:

!foo x=20
!bar x=10

A let expression may bind multiple names at once, which apply immediately in the order they are given, and supports using indentation for continuance. For example:

let x=1 y=2
    z=x*y

A let binding may also specify multiple names separated with semicolons to do an unpacked vector binding. For example:

let x;y=SIZE/2

This will pick off the first two items from the vector result of evaluating the expression and bind them in order to the names x and y. The above code is roughly the same as:

let src=SIZE/2
    x=src[0]
    y=src[1]

Therefore, unpacked binding follows the rules for indexing. If the source vector is longer than the number of names given then additional items are ignored. If the vector is shorter, then the the additional names will be bound to items wrapped around from the start again. If the vector is null then all names will be bound to null.

A let binding may also bind one or more names to the result of evaluating an indented “body” sequence of expressions. For example:

let foo=
    !foo bar=12
        !baz

A let binding of this form may only have one name (or semicolon-separated list of names for an unpacked vector binding) followed by an =, a newline and then an indented sequence of expressions. This sequence let may contain any multi-line sequence expressions, the same as the body of a function or a loop. This is particularly useful for binding nested node structures to a name.

If a semicolon-separated name list is provided in a sequence let, then the names will be bound to values following the unpacking logic described above, with the vector formed from the indented body being the source vector.

Names introduced with a let can redefine engine-supplied values, like beat, and built-ins, like sin.

Note

Flitter supports primes in names. This is a common mathematical convenience for a new unique name that references another. For example:

let x' = x + 1

All identifiers may contain any number of single quote characters at the end.

Where

There is also an inline version of let known as where. This allows names to be bound within a non-sequence expression, e.g.:

!foo x=(x*x where x=10)

It is good practice, although not always necessary, to surround where expressions with parentheses to make the scope clear. However, note that where has higher precedence than ; vector composition and so (x;x*x where x=10) is equivalent to x;(x*x where x=10).

Sequences

The result of evaluating an indented sequence of expressions separated by line breaks – such as might be found in the body of a loop, conditional or function – is the vector composition of the result of each expression. These need not necessarily be node expressions, it is perfectly normal for functions to operator on, and return, numeric or string vectors. For example:

func fib(n, x=0, y=1)
    x
    if n > 1
        fib(n-1, y, x+y)

This recursive function returns an n-item (at least one) vector of Fibonacci numbers beginning with x;y.

debug(fib(10))

will output the following on the console:

0;1;1;2;3;5;8;13;21;34

Conditionals

if test
    expression
    
elif test
    expression
    …》
else
    expression
    …》

test is any expression and it will be considered true if it evaluates to a non-empty vector containing something other than zeroes or empty strings. So 0 is false, as is null, "" and 0;0;0. The result of evaluating the matching indented expressions is the result value of the if. In the absence of an else clause the result of an if/elif with no true tests is null.

There is an in-line expression version of if that borrows its syntax from Python:

!fill color=(1 if x>10 else 0)

If the else is omitted then the expression will evaluate to null if the condition is not true. Importantly, this means that using a bare if in an attribute setting operation will result in the attribute being unset if the condition is false, i.e., in:

!window size=100;100
    !canvas
        !group color=1;0;0
            for x in ..10
                !path
                    !rect point=x*10+2.5;0 size=5;100
                    !fill color=((0;1;0) if x >= 5)

the color attribute will not be set on the second five !fill nodes and so they will inherit the color from the enclosing !group. Thus, this will draw five thick green lines followed by five red.

In-line conditionals do not have an elif equivalent, group multiple conditional expressions as necessary to achieve this, e.g.:

!foo x=(x if x < 10 else (x*2 if x < 20 else x*3))

For Loops

for name;name…》 in expression
    expression
    

For loops iterate over vectors binding the values to the name(s). The result of evaluating the expressions within the loop body are composed into a single vector that represents the result value of the loop. Normally this would be a vector of nodes to be appended to some enclosing node.

When multiple names are given, each iteration will take multiple values from the source vector. Unlike unpacked vector binding, if there are not enough values left in the source vector to bind all names in the last iteration, then the names lacking matching values will be bound to null, i.e., a for loop cannot iterate past the end of the source vector.

Iterating with multiple names is particularly useful combined with the zip() function, which merges multiple vectors together:

!group #clockface
    let theta=(..12)/12
    for x;y in zip(cos(theta), sin(theta)) * 100
        !ellipse point=x;y radius=10
    !fill color=1

Note that cos() and sin() here are taking a 12-vector and returning another 12-vector, zip() combines these into a 24-vector and the multiplication by 100 is applied to every element of this. Also of note here is that the flitter versions of cos() and sin() take values in turns not in radians. The dozen !ellipse nodes created by the loop are combined into a 12-vector, this has the final !fill node composed with it and then all of these nodes are appended to the !group node.

There is actually a more convenient polar(theta) function that does the same thing as zip(cos(theta), sin(theta)). Arguably, it would be even neater to implement a clock face using !transform rotate= instead.

Some syntactic sugar is provided to make directly nested loops more readable. For example:

for x in ..N
    for y in ..N
        for z in ..N
            !sphere position=x;y;z size=0.5

may be written as the more concise:

for x in ..N, y in ..N, z in ..N
    !sphere position=x;y;z size=0.5

Again, loops may also be used in-line in non-sequence expressions with syntax borrowed from Python:

!line points=((x;x*5) for x in ..5)

This will evaluate to:

!line points=0;0;1;5;2;10;3;15;4;20

There is no special syntax for nested in-line loops as these can already be simply written as:

let grid = (x;y) for x in ..N for y in ..N

It is worth noting that – following the reversed notation – these loops operate in the reverse order to nested non-inline loops, in that this example is equivalent to:

let grid = ((x;y) for x in ..N) for y in ..N

and therefore the last loop is outermost.

In-line for loops can be combined with in-line if expressions to filter elements from the result vector. As the result of a false inline if lacking an else is null, and a null composed into a vector is ignored, one can use the form:

let xs = x*2 if f(x) > 3 for x in ..100

Function calling

Functions are called in the normal way, with the name of the function followed by zero or more comma-separated arguments within parentheses, e.g., cos(x) or zip(xs, ys).

Most built-in functions will do something sensible with an n-vector, e.g., sin(0.1;0.2) will return a vector equivalent to sin(0.1);sin(0.2), but will be substantially faster for long vectors. Some functions operate with both multiple arguments and n-vectors, e.g.:

hypot(3;4) == 5
hypot(3, 4) == 5
hypot(3;30, 4;40) == (5;50)

As functions are themselves first-order objects in the language, they may be composed into vectors. The language takes the orthogonal approach that calling a function vector is identical to composing the result of calling each function with the arguments, i.e., (sin;cos)(x) == (sin(x);cos(x)). This is arguably obtuse behaviour.

The Flitter built-in functions are documented separately.

Function Definitions

func name(parameter=default》《, parameter…》)
    expression
    

func will create a new function and bind it to name. Default values may be given for the parameters and will be used if the function is later called with an insufficient number of matching arguments, otherwise any parameters lacking matching arguments will be bound to null. The result of evaluating the body sequence will be returned as a vector to the caller.

Functions may be declared anywhere in a program including within another function definition. Functions may refer to names defined outside of the function definition. The values of these names will be captured at definition time and so rebinding a name later in the same scope will be ignored. For example:

let x=10

func add_x(y)
    x+y

let x=20

!foo z=add_x(5)

will evaluate to !foo z=15 not !foo z=25.

A function definition is itself an implicit let that binds the function definition to the function name in the definition scope. Functions are values in the Flitter language and may be manipulated as such. Functions may also recursively call themselves. That is, the function name is in scope within the body of the function.

Function calls to user-defined functions may include out-of-order named arguments, e.g.:

func multiply_add(x, y=1, z)
    x*y + z

!foo w=multiply_add(2, z=3)

will bind the arguments to parameters with x taking the value 2, y taking its default value 1 and z taking the value 3. Named arguments must be given after any positional arguments and should not repeat positional arguments (they will be ignored if they do).

Functions that that do not reference any non-local dynamic names within the body (including recursive functions) are eligible for inlining by the simplifier at each call site. The simplifier is able to bind the parameters to the argument expressions and continue simplifying the body on that basis. Therefore, it is often more performant to pass dynamic values (such as beat) into the function as parameters than allow them to be captured from the environment.

Anonymous functions

Flitter also allows anonymous functions to be defined and used as values with the syntax:

func (parameter=default》《, parameter…》) body

The body of an anonymous function may only contain in-line expressions. In the grammar, anonymous functions have higher precedence than ; composition but lower than inline for and where expressions. Therefore, the following defines a function containing a for loop, as might be expected:

let f = func(x) x*y for y in ..10

However, the following binds f to a 3-vector consisting of an anonymous function, and the numbers 1 and 2:

let f = func(x) x;1;2

Note that calling this f is not in itself an error, as a call to a vector is valid - as explained in Function calling above. The anonymous function will be called and the attempted calls to the two numbers will log evaluation errors and otherwise be ignored. So f(0) will evaluate to just 0.

Any node expression (other than a simple !kind) must be parenthesised. The following superficially sensible code is incorrect:

let f = func(c) !light color=c

As it is parsed as:

let f = func(c) !light
let color = c

An anonymous function may return another anonymous function, e.g.:

let f = func(x) func(y) x + y

As with regular functions, any captured names are bound at the point of definition and so the value of x passed into the first function call will be bound into the returned anonymous function

Template function calls

The special @ operator provides syntactic sugar for calling a function using syntax similar to constructing a node. The name following @ should be the name of the function to be called, any “attributes” placed after this are passed as named arguments. Any indented expressions are evaluated and the resulting vector passed as the first positional argument to the function, otherwise the first argument will be null.

A template function call like this:

@fname name1=value1 name2=value2 
    sequence

roughly translates to:

let nodes=
    sequence
fname(nodes, name1=value1, name2=value2, )

As normal, function parameters that are not bound will take their default value if one is given in the function definition or null otherwise.

For example:

func shrink(nodes, percent=0)
    !transform scale=1-percent/100
        nodes

!window size=1280;720
    !canvas translate=640;360
        @shrink
            !path
                !ellipse radius=100
                !fill color=0;1;0
        @shrink percent=25
            !path
                !ellipse radius=100
                !fill color=1;0;0

This (rather pointless) example draws a 100px radius circle in green and then draws another circle 25% smaller in red on top. Both !path nodes will be wrapped with !transform nodes, the first with scale=1 and the second with scale=0.75. The !transform nodes returned by the two template function calls are appended to the !canvas node.

As template function calls convert into regular function calls, templates may be used recursively.

Other ways to use template function calls

Although template function calls are designed to allow custom node-like operations to be defined, they can be used in other ways. A particularly useful application is debugging node construction by adding a @debug call. As the debug() function logs and returns its first argument, it can be inserted at any point in a node tree to see what has been constructed without altering the tree. For example:

!window
    !canvas3d viewpoint=1
        !light color=1 direction=-1
        @debug
            for i in ..10
                !sphere position=beta(:position;i)[..3]-0.5 size=0.1

Similarly, template function calls can be used in functions to wrap a sequence of expressions. For example:

func onoise(seed, n, k, x, y)
    let weights=k**i for i in ..n
        total=sum(weights)
    @sum
        for i in ..n
            let scale=2**i
            noise(seed, x*scale, y*scale) * weights[i] / total

This function is (almost) equivalent to the built-in function octnoise() and makes use of @sum to add up all of the calls to noise(). Although this could have been done with sum() and an in-line for loop, this code is more readable.

State

All mutable state in Flitter, such as the outputs of MIDI controllers, the values of counters or the properties of particle physics systems, is contained within the state mapping.

State look-ups are done with the $ prefix operator, which should be followed by a state key. A key is any vector of numbers, symbols and/or strings. As the ; operator binds with lower precedence than $, a composed key must be surrounded with parentheses, e.g., $(:circle;:radius).

For example:

let SIZE=1280;720

!window size=SIZE
    !canvas size=SIZE translate=SIZE/2
        !ellipse radius=$:circle_radius
        !fill color=1;0;0

!controller driver=:xtouch_mini
    !rotary id=1 state=:circle_radius lower=0 upper=300 initial=100

This shows an X-Touch mini MIDI surface being configured with one encoder as a knob that changes the radius of a red filled-circle drawn in the middle of the window. The state=:circle_radius attribute of the !rotary node specifies the state key to write values to and the current value is retrieved with $:circle_radius.

Pragmas

One or more pragmas may be placed at the top of a source file before any other expressions. Pragmas take a name and a single literal number or string.

There are three currently supported pragmas:

%pragma tempo 110
%pragma quantum 3
%pragma fps 30

These respectively set the initial tempo and/or quantum of the main clock (the defaults are 120 and 4, respectively), and the current target frame rate of the engine (default is 60 or the value specified with the --fps command-line option).

Imports

Top-level definitions (lets and funcs) may be imported from one Flitter program file into another. This allows common definitions to be collected into modules that can be used elsewhere. For example:

import SIZE;thing from 'common.fl'

!window size=SIZE
    @thing

The import from expression will execute the module with the supplied file name (interpreted as a string file name relative to the current program’s source file) and include one or more names, separated with semicolons, into the current program as if they had been defined with a let or func at this point. Names not defined in the module will be bound to null.

The module is executed in a new context and so top-level node expressions will not be added to the current tree, any pragmas will be ignored and all state look-ups will evaluate to null. All built-in names will be defined in a module, but not runtime names (such as beat) or command-line names (supplied with --define). To use any of these values in an imported function they must be passed in as arguments. However, called functions do have access to the current state.

During execution of an imported module, any calls to file built-ins – such as read() – will resolve filenames relative to the path of the module. However, if a function in a module is called from the main program, and that function calls any file built-ins, then these will resolve filenames relative to the path of the main program, not the module.

Modules may import from other modules, provided that a cycle is not created: if a module attempts to import a module further up the current import chain, then an error will be output to the console and all of the names for the failing import will be bound to null.

Parsing

Flitter uses an LALR(1) parser with a contextual lexer. This means that, in general, there are no reserved keywords. The following is perfectly legal code that will parse and run:

let let=..10

for in in let
    !let let=in

This is convenient in allowing things like from to be used as a keyword in an import from expression and also as an attribute name in a !distance from= physics node. However, it would be best not to rely on this flexibility too much as it will fail in many contexts. For instance, this very similar code to the above will not parse:

let let=..10

for in in let
    !let for=in

This is because for cannot be used as an attribute name here as it is also a valid parser match at this point for the for keyword of an in-line for expression.

Simplification

When a source file is loaded it is parsed into an abstract syntax tree and then that is partially-evaluated by the simplifier. This attempts to evaluate all static expressions. The simplifier is quite sophisticated and is able to construct static parts of node trees, unroll loops with constant source vectors, evaluate conditionals with constant tests, call functions with constant arguments (including creating pseudo-random streams), inline functions that contain only local names, replace let names with literal values, evaluate mathematical expressions (including some rearranging where necessary to achieve this) and generally reduce as much of the expression tree as possible to constant values.

The simplifier is also able to evaluate imported modules and pull static names directly out of them - including inline-able functions. As imports are always evaluated statically (see Imports), this means module imports can usually be entirely collapsed at compile-time. The engine will maintain a set of file dependencies when this happens and will re-compile the running program if the source of an imported module changes.

Known global names (like beat) are always dynamic and so any expressions that include these will, generally, be dynamic. However, unbound names are interpreted as null values, therefore the simplifier will simply replace these with static nulls, issuing a warning as it does so. It is sometimes useful to leave unbound names in a program in order to allow behaviour to be switched at run-time using the --define NAME=value command-line option. As the simplifier is able to follow these null values through conditionals, it will not affect performance and such uses can be considered similar to #if preprocessor instructions in other languages.

After the simplifier has partially-evaluated the tree (and note that thanks to loop unrolling and function inlining, “simpler” most often doesn’t mean “smaller”), the tree is compiled into instructions for a stack-based virtual machine. These instructions are interpreted to run the program.

The simplifier and compiler can run again incorporating any state that has been stable for a period of time (configurable with the --simplifystate command-line option). If any of these state keys then changes (i.e., a pad or encoder is touched) the engine will immediately return to the original compiled program. Programs that involve large static loops may take a noticeable amount of time to re-simplify and so it may be necessary to turn off simplification on state in programs that involve unpredictable state changes (--simplifystate=0).

The simplifier can be completely disabled with the --nosimplify option.

Run-time Error Behaviour

Flitter is designed for live performance and so makes every attempt to charge on in the presence of errors. Most erroneous behaviour is simply silently ignored – such as numerical operations on non-numerical values resulting in nulls. Some erroneous behaviour will log warnings or errors – such as using an unbound name or calling a built-in function with an incorrect number of arguments – before also evaluating to null.

If a live code change makes a program un-parseable then the engine will log a parser error and continue executing the previously loaded version of the file. Unfortunately, this does not hold for GLSL shader code, which resolves to strings that are parsed and compiled on-the-fly and so parse/compile errors in this will cause the runtime to ignore the code completely. This will cause !shader nodes to fall-back to their default pass-through behaviour and models contained in a !canvas3d group with a custom shader will fall back on the default shader.

It is generally a good idea to keep the console log from Flitter visible when making live changes to be able to spot errors if they come up.