Skip to content

The Trait language

Trait is the language a lusine game is written in: its data, the laws that change it, and the steps that apply them. This page is for a game's author. It walks through what you write, with examples taken from lusine's library and its kernel pages, and ends with what the compiler refuses and how a game gets built and run.

A Trait program reads like C where it computes and like SQL where it works on tables. You never write a loop over a board, a GPU buffer or a shader: you state the rule for one item, and the compiler runs it over all of them on the graphics card.

A first look

This is the Game of Life, whole:

namespace life;

lusine.version version = { { number = 1 } };

struct cell { bool alive; };

lattice<cell, 2> board(spacing = 1 m, boundary = periodic);

struct { integer population; } totals;

bool generation(bool alive)
{
  let n = count(previous(neighbours(8).alive)) where alive;
  if (previous(alive)) return n >= 2 && n <= 3;
  return n == 3;
}

integer census(table<bool> alive) { return count(alive) where alive; }

void main()
{
  board.alive = generation(board.alive);
  totals.population = census(board.alive);
}

It has the four kinds of thing every program has:

  • declarations: the namespace, a struct, a grid of cells and a single record of totals;
  • kernels: generation and census, pure functions;
  • bindings: the two lines of main, each storing what a kernel returns into the data;
  • procedures: main, the step, a list of bindings run in order.

Namespaces and versions

A file starts by naming the namespace its declarations belong to:

namespace mygame;
  • Inside it you write each name bare (board), and everyone else reaches it qualified (mygame.board). A sub-namespace (namespace mygame.laws;) sees its own names, then its parents', then the root's; a name found at two of those levels has to be written qualified.
  • Names under lusine. are the library's. Your game uses them and declares its own items of their types, but declares nothing inside lusine..
  • Every namespace declares its version exactly once, and you raise it when you change the game: lusine.version version = { { number = 1 } };. A sub-namespace shares its parent's.

A game spans as many files as it likes; files naming the same namespace add to it.

Types and units

Every number in Trait carries its unit, and the compiler checks that the units of every expression agree.

Type What it holds
float<u> a real number in the unit u: float<kg/m3> density;
integer<u> a whole number of steps of a quantum in the unit u: integer<kg> mass quantum = 10 ug;
float, integer the same, dimensionless
bool true or false
bits a 32-bit word for hashes and seeds: no unit, wrapping arithmetic, the bitwise operators
vector<T, N> N values of T, from two to four: vector<float<m/s>, 3> velocity;
relation<S> a reference to a row of the table S, or none
text words, for the host: a kernel cannot take or return text
ref<T> the name of a stored quantity, held in a data row (see Expansions)

Units are spelled as you would write them: m, kg/m3, W/(m K), m^-2, km2, J/(kg K). Every SI prefix applies (km, mg, us), and degC and degF are absolute temperatures that convert through their offsets. A literal names its unit (300.0 K, 9.81 m/s2); a bare number is dimensionless.

A float is written with a decimal point and an integer without one: 1.0 and 1 are different, and using one where the other is declared is refused.

A quantity starts at zero in its type (0, 0.0, false, none) unless it gives a value: float<s> age = 0.0 s;.

_ stands for any unit in a kernel's signature, the same unit wherever it appears in that signature, fixed by each call. The library's drag law is written once for any vector length this way, the _ in the vector's size fixed by the binding (its page):

vector<float<m/s2>, _> drag(vector<float<m/s>, _> velocity, vector<float<m/s2>, _> gravity, float<s^-1> drag_rate)
{
  return gravity - drag_rate * velocity;
}

Results are the same on every machine. A float is IEEE single precision, rounded after every operation, and sums over many items add in a fixed order, so a step gives the same bits on every graphics card and on every run. A procedure you mark fast (below) gives that up for speed.

none flows through. Arithmetic with none gives none, a condition that is none is false, and storing none leaves a value as it was. A relation to a deleted row, a missing cell of a data row and the minimum of nothing are all none.

Qualifiers and storage

A declaration may start with a qualifier, saying who writes it:

  • host: the host (your C++, a panel, a tool) can see and set it. Use it for settings: host struct { float stride = 0.0005; } settings;.
  • input: only the host writes it, never a binding. lusine's clock and its key and pointer tables are inputs.
  • const: nothing writes it after the program loads, such as a table of rows given in the source.
  • nondeterministic: a float whose sums may add in any order, faster, at the cost of differing slightly from run to run.

After the name and value, an integer or float may say how it is stored: quantum = 10 ug (the step an integer counts), range = 0 .. 255, and overflow = wrap | saturate | refuse, what a value outside the range does. refuse keeps the old value and reports an error.

struct cell { bool alive; integer age range = 0 .. 255 overflow = saturate; };

Integer division and remainder are C's: -7 / 2 is -3, -7 % 3 is -1, and dividing by zero gives none. float(n) turns an integer into a float in its unit.

Data: structs, lattices and tables

A struct describes one item. A variable of a struct type is a single record; a container of it holds many.

struct { integer step = -1; float water; } sweep;

A lattice is a regular grid of items, any number of dimensions:

lattice<cell, 2> board(spacing = 1 m, boundary = periodic);
  • boundary is wall, periodic or open. Its size is set when the game runs (the --size and --extents options), not in the source.
  • A lattice knows its neighbours: neighbours(8), adjacent(+x), adjacent(-y), across, opposite, within(2 m), closest(k) (see Reading beyond the item).
  • A coarser lattice may sit over a finer one, each of its cells a block of the finer one's: lattice<tile, 2> tiles(above = board, branching = 8);. A kernel bound over the coarse lattice reads its block's cells as below, and a binding over the fine one reads its cell's tile as it.tiles.

It may have a key, a field (or several) that names each row:

struct gas { text name; float<kg/mol> molar_mass; float<J/(kg K)> specific_heat; };
table<gas> gases(capacity = 8, key = name) = {
  { name = "N2", molar_mass = 0.0280134 kg/mol, specific_heat = 1040.0 J/(kg K) },
  { name = "O2", molar_mass = 0.0319988 kg/mol, specific_heat = 918.0 J/(kg K) },
};
  • gases["N2"] is the row whose key is "N2". With a key of two or more fields, give each in turn: phases[substances["steel"], states["solid"]].
  • Rows given in the source, as above, are loaded with the program. A cell a row leaves out is none, never a default.
  • keep = 1 step removes a row at the start of the step after the one that added it: how event tables stay small.

A relation points at a row, and follows it whatever its key becomes. Rows of the table above are related to from another table, this one from the gas mixture's live page:

struct part { relation<mixtures> mixture; float fraction; relation<gases> gas; integer role; };
table<part> parts(capacity = 32) = {
  { mixture = mixtures["air"], fraction = 0.78084, gas = gases["N2"] },
  { mixture = mixtures["air"], fraction = 0.20946, gas = gases["O2"] },
};

A deleted row's relations read none from then on.

A struct can extend another, adding fields: struct grain : holding { float moisture; };. A table of the base type holds any mix of derived rows, and a kernel tests which it has with is: if (h is grain) return h.moisture;.

Kernels

A kernel is a pure function: what it reads are its parameters, what it does is return. It has no side effects and no loops.

bool rise(bool signal)
{
  return signal && !previous(signal);
}
  • The signature is C's: results, name, parameters. Several results are a tuple, (integer, float) census2(...).
  • A kernel runs once for each item of whatever its binding names. A plain parameter is that item's own value.
  • let names a value, once: let rate = diffusivity * dt / (extent * extent);.
  • if and else take blocks; the operators are C's, with pow(a, b), length(v), abs, min, max, clamp, floor, ceil, exp, log, sin, cos and the rest as functions.
  • A vector is built by naming its type: vector<float, 4>(r, g, b, 1.0).
  • A struct is built as a data row is: return order { quantity = wanted, price = offer };.
  • it is the current item itself, for comparing a relation with it.
  • previous(x) is x as it was when the step began.

A kernel that names only its parameters works for any game: that is how the library is written. A kernel may also name your game's data directly, which makes it your game's alone.

Folds

count, sum, min, max, any and all fold a set into one value, and take a where after the closing bracket, as in SQL:

let n = count(previous(neighbours(8).alive)) where alive;

Inside the where, a bare name is the member being folded. Another fold from the library, the weighted mean, sums a table's rows that belong to the current item:

float weighted_mean(list<struct { relation owner; float value; float weight; }> members)
{
  let total = sum(weight) where owner == it;
  if (total <= 0.0) return 0.0;
  return (sum(value * weight) where owner == it) / total;
}

A list<struct { ... }> parameter takes any table of yours with those fields, so one law serves many games. A float sum always adds in the same order, so it gives the same result on every run; min and max of nothing are none.

Reading beyond the item

A . through a relation reads another row: trader.cash, gas.molar_mass. Paths chain: neighbours.material.conductivity.

On a lattice the neighbourhoods are named:

Name On a lattice On a table
neighbours, neighbours(N) the surrounding cells, or the N nearest its declared neighbour relation
adjacent, adjacent(+x) the cells sharing a face, or the one along an axis its declared neighbour relation
across, opposite the second shell; the cell across every axis refused
within(r), closest(k) the cells within r; the k nearest the same, over the table's positions

A path through a set is a set, and arithmetic over it is member by member. The library's diffusion sums a flux over the adjacent cells:

integer<_> diffusion(integer<_> amount, float<m2/s> diffusivity, float<m> extent, float<s> dt)
{
  let rate = diffusivity * dt / (extent * extent);
  return sum(diffusion_flux(mine = amount, theirs = adjacent.amount, rate));
}

An offset's argument is a constant or a value with a declared range, never arithmetic: compute it with a let first.

Bindings

A binding stores what a kernel returns. Its left side names the data, and that data's items are where the kernel runs:

board.alive = generation(board.alive);
(board.position, board.velocity) = lusine.integrator.kick_drift(board.position, board.velocity, board.acceleration, settings.dt);

Arguments bind by name. An argument whose last part matches a parameter's name binds to it (board.alive to alive); any other is named explicitly, survive_min = settings.min_neighbours. Every item's new value is worked out from the old values first, and only then written, so items never read each other's half-finished results.

Form What it does
board.alive = rule(board.alive); stores a value for each item
board = fall(board); replaces whole items
totals.burnt += burnt(board.fuel); credits a quantity, a sum in no particular order with the same result
fills.trader.cash += settle(fills.amount); credits each fill's own trader through the relation
trades += fill(orders) where orders.quantity > 0; appends the rows the kernel returns
orders -= expired(orders); removes each row for which the kernel says true

A where on a binding says which items it applies to: board.alive = generation(board.alive) where it.tiles.live;.

The same binding can be written as SQL, over a table or a lattice, and means exactly the same:

update board set alive = generation(alive);
update fills set trader.cash += settle(amount);
insert into trades select fill(orders) from orders where quantity > 0;
delete from orders where expired(age);
insert into trades select crossing(b, s) from orders b join orders s on b.listing == s.listing where b.buy && !s.buy;

The last is a join: one row appended for each pair of rows it takes, in the same order every run.

An ordered walk handles rows in an order, each seeing the ones before it through before(x): throws.refused = rejected(throws.fit, before(throws.fit)) where throws.pending order by throws.arrival;.

Procedures and the step

A procedure is a named list of bindings, a void function with no parameters. main() is the step; the host runs it once a step, and runs any other procedure nothing calls by its name, such as a start run once.

void start() { bodies.acceleration = lusine.laws.drag(bodies.velocity, settings.gravity, settings.drag_rate); }

void main()
{
  (bodies.position, bodies.velocity) = lusine.integrator.kick_drift(bodies.position, bodies.velocity,
                                                                    bodies.acceleration, settings.dt);
  bodies.acceleration = lusine.laws.drag(bodies.velocity, settings.gravity, settings.drag_rate);
  bodies.velocity = lusine.integrator.kick(bodies.velocity, bodies.acceleration, settings.dt);
}

That is the falling body of the drag page. Inside a procedure:

  • bindings run in the order written, each seeing the ones before it;
  • a procedure may call another, which runs inline where it is called;
  • if (...) chooses on the GPU between bindings;
  • while (changed(q)) limit N ... repeats until q stops changing or the limit is reached: while (changed(ground.steps)) limit count(ground.steps) ground.steps = relax(ground.source, ground.steps);. A loop always states its limit; one that hits it reports an error and the step carries on.

A procedure may be marked:

  • host: the host may run it on its own, as well as where it is called;
  • render: it draws, run for each frame drawn rather than as a step;
  • fast: it uses the graphics card's own division and functions, faster, its results the same on one machine from run to run but not across machines. It suits drawing, never the simulation.

A binding may be marked instrument: a check that runs in a development build and is left out of a release, which then computes the same values.

Expansions: each

each repeats statements once for every row of some constant data, when the program is built. It is how lusine acts on rows your game declares, its checks first among them. A row's ref field names a quantity of yours, and case sends each row to the statements that suit its type.

struct conserved : check { ref<table<_>> target; float tolerance; };

You will mostly use it by declaring rows of the library's types, below.

What lusine reads of your program

A game talks to lusine through ordinary data. lusine reads every declaration of its types, in any of your namespaces:

  • Checks: rows of a table<lusine.check>. lusine.conserved holds a column's total from step to step, within a tolerance for floats:
table<lusine.check> checks = {
  lusine.conserved { { target = board.mass, tolerance = 1e-6 }, { target = board.grains, tolerance = 0.0 } },
};
  • Errors: a failed check, a refused store and a loop past its limit are reported to the engine, which logs each, and a run that reported one fails.
  • Input: lusine.input.keys and lusine.input.pointers, the keys and pointer events of the step (A desktop game lists their fields). lusine.keyboard.track(); keeps which keys are held.
  • The clock: lusine.clock, with the step's dt, the steps so far and the time.
  • Sound: lusine.sound's block of samples, which the engine plays (A desktop game describes the wiring).
  • Panels: rows of a table<lusine.widget>, which the engine draws beside your board.
  • Units: rows of a table<lusine.unit> add units of your own, such as a currency: { name = "EUR", base = "money" }.

Pictures are quantities too: a render procedure writes a column of colours (vector<float, 4>) on a two-dimensional lattice, and the engine shows it in a viewport.

When the compiler refuses

The compiler checks the whole program when you build, and refuses anything it cannot run as written. A refusal names the file, the line and the column, a code, and what is wrong:

trait/mygame.trait:10:18: unit: + takes one dimension, and these are a float of length and a float of time
trait/mygame.trait:12:21: binding: of is no parameter of mygame.lonely (trait/mygame.trait:8:6); its parameters are on

Fix the line it names, by what the message says: here, the first adds a height to an age, and the second misspells an argument, and lists the parameters it could have meant. A refusal never means the rule is wrong; it means the program says something the rule does not allow.

Code What it refuses
syntax something that does not parse; the first such place is named
unresolved a name that matches no declaration
ambiguous a name that matches two, or a let named twice
owner a declaration in someone else's namespace; a namespace with no version, or two
unit units that do not agree; a float written as an integer or the reverse
width an integer whose range needs more than 64 bits
binding a kernel's parameter given nothing, or given something of another type, unit or size; an argument that is a column of other data than the binding's
path a neighbourhood the data does not have; a set where one value is wanted, or the reverse; arithmetic as an offset's argument
input a binding writing an input quantity; anything writing a const one
text text in a kernel
effect an append, a removal or a credit inside a kernel, rather than in a binding
coordinate a library kernel reading a cell's coordinates
restated a data row giving a value that is computed; a constant written out where the library has it
absent a read through a relation that may reach a row lacking that column, unguarded
kind rows added to or removed from a lattice; a fact the container already gives, such as its size
ref a ref to something that is not a stored quantity, or is one of the wrong type
instrument a statement depending on what only an instrument binding writes
case a row that no case of an each takes, or that two take
return a kernel path that ends without returning
recursion a kernel or procedure that ends up calling itself

Things Trait does not have, by design: loops or recursion inside a kernel, effects inside a kernel, text on the GPU, subscripts, and anything of the host's (command lines, logging, windows, audio playback), which lusine's engine does for you.

Building and running a program

On the desktop, your game's CMakeLists.txt names its Trait sources, and the build compiles them with lusine's library:

lusine_game_executable(mygame SOURCES src/main.cpp TRAIT trait/mygame.trait)

trait.toml beside it tells the compiler where the library is (library = ["lusine/library"]). Building compiles the program into the game's unit and its shaders; a refusal fails the build with the messages above. Your short C++ main hands the run to lusine: the window, the steps, the panels and the reads (A desktop game). The program's quantities reach your C++ as types named after them, such as life::totals::population.

In a browser, pack.mjs compiles the same source and writes a page that runs it on WebGPU, its options the whole setup (A web game):

node lusine/tools/lusine/web/pack.mjs --out web --extents 160x90 --seed mygame.board.on --pattern random:1:0.5 --single trait/mygame.trait

While it runs, a game started with --control takes Trait from tools: new procedures and outputs compiled against the running game, called by name between steps (the control channel).

Your first game does all of this from an empty folder, and the library lists every kernel you can call, each with a page that checks it.