Skip to content

A desktop game

A desktop game is a program for Windows, Linux or macOS that runs on the graphics card through Vulkan. It has three parts: its Trait source, one CMake call, and a short C++ main. Your first game makes one from lusine's template; this page is what each part can do.

The Trait source

Everything the game computes is in its .trait files: its data, its rules, and the colours it shows. The compiler reads them together with lusine's library, which trait.toml names:

library = ["lusine/library"]

The window shows a grid of colours your program writes: a column of four numbers (red, green, blue, alpha, each from 0 to 1) on a two-dimensional lattice, such as mygame.board.colour in the starter.

The build: one CMake call

cmake_minimum_required(VERSION 3.28)
project(mygame LANGUAGES CXX)
add_subdirectory(lusine)
lusine_game_executable(mygame SOURCES src/main.cpp TRAIT trait/mygame.trait)

lusine_game_executable runs the Trait compiler on your source at build time, compiles the shaders it writes, and makes:

  • the executable, with its shaders in a folder beside it (mygame/ on Windows, mygame_data/ elsewhere);
  • a test, mygame_run, which runs 60 steps with no window;
  • a target that makes the shipping zip (see Building a game).

Its options: RUN_FRAMES <n> for a test of another length, NO_RUN_TEST for none, DATA <dir>... for folders shipped beside the executable, LICENCE <file> for the licence put in the zip, and UNIT <name> to name the compiled unit. More C++ sources go after SOURCES, more Trait files after TRAIT.

The C++ main

The main fills in two structures and calls RunGame, which does the rest: the log, the command line, the device, the window, the loop and the exit code.

lusine::core::CoreRun run;
run.spirv = LUSINE_GAME_SPV;
run.defaults = { { { 160, 90 } }, { { "mygame.board.on", 0.5 } } };

FrameLoopGame game{};
game.name = "mygame";
game.latticeView = "mygame.board.colour";
game.simulateRate = 60.0;
return lusine::core::RunGame(argc, argv, game, std::move(run));

CoreRun, the run:

  • spirv: where the shaders are; LUSINE_GAME_SPV is the folder beside the executable.
  • defaults: the size of each lattice, and the columns filled at random at the start with what share of cells on, when the command line does not say.
  • panels: rows describing panels the engine draws (below).
  • host: what your C++ does each step, all optional: reads, the values to read back; collected, what to do with them when they arrive (one step later); before, writes to make before a step (a value stored, a row added); load, a starting state of your own; dt, the seconds a step simulates, if not one over the step rate.
  • flags: your own command-line options.
  • started and verdict: a check before the run, and one after it that decides whether the run passed.
  • tuning: where tuning.json is read, if not beside the executable.

FrameLoopGame, the window and the loop:

  • name, and window, its starting size in pixels (1280 by 720 unless you say).
  • simulateRate and renderRate: at most this many steps, and frames, a second; 0 for no limit. interpolate (on by default) draws between the last two steps when frames outnumber steps.
  • latticeView: one column shown over the whole window. Or viewports: several rectangles of the window, each showing a column with its own pan and zoom (fixed, aimed by your C++ each step, or read from values your simulation writes) and, for a rendered picture, a render scale. viewportControl replaces the list while the game runs.
  • readouts: a line of text for the window's title, each frame.
  • drawPanels: your own ImGui drawing, each frame.

Here is how the Life example game reads its population back and shows it, from its main:

run.host.reads = lusine::core::EveryStep({ lusine::core::ReadOf<life::totals::population>() });
lusine::core::Widget plot;
plot.kind = lusine::core::WidgetKind::Plot;   // its name and current value, then a chart of its last steps
plot.panel = "life";
plot.quantity = "life.totals.population";
plot.steps = 600;
run.panels = { plot };
run.host.collected = [&](const lusine::core::Core& core, const lusine::core::Readback& back) {
    std::vector<int64_t> pop;
    if (core.Items<life::totals::population>(back, pop) && !pop.empty()) {
        population = pop[0];
        ++collected;
    }
};

The panel rows need no ImGui code: a Value row shows a value and its unit, a Plot row its last steps values, a Words row a line of text by its catalogue key (the engine shows the key itself for now: it does not read the catalogue yet), and a Series row a line of a chart. life::totals::population is a type the compiler wrote for the program's totals.population.

Three more kinds show a table of your game:

  • A table widget: Table rows sharing a table name are one table, each row a column (quantity, a column of one of your tables). It shows the table's live rows, a heading per column with its unit. A relation column names the related table's column it shows (shows). Clicking a heading sorts the rows; your game never sees the sort.
  • A selected row: a table whose rows name selected, a host relation<listings> selected; your game declares, writes the row a player clicks into it before the next step. Your Trait reads it and drives everything from it: the next panel, a form's defaults, a lesson. The row shown as selected is the one that quantity holds, so the engine keeps no selection of its own.
  • A message list: a Messages row names a table's key column (quantity, with shows where it is a relation to a table of keys) and Slot rows its slots, each a column. It shows a line per row, newest first (by an order column your game writes, or the newest slot), at most newest lines. A slot prints its column: a number with its unit, a relation by its shows. The engine shows each line's key and slots for now; the words will be the catalogue's.
  • A panel shown while a bool holds: a Showing row hides its panel until the bool it names is true, so your Trait unlocks a panel by writing it.

A panel never computes: anything it shows that needs computing, a filter or an order or a line's wording, is a column your Trait writes or a catalogue entry. The same holds for input: a panel's input appends a row to one of your input tables the way a client's append over the control channel does, with the same receipts, and a field the player leaves alone takes its value from a column your game writes or a constant in the widget's row.

lusine::core::Widget name;
name.kind = lusine::core::WidgetKind::Table;
name.panel = "market watch";
name.table = "listings";
name.quantity = "bourse.listings.name";
name.selected = "bourse.selected";             // host relation<listings> selected;
lusine::core::Widget exchange = name;
exchange.quantity = "bourse.listings.exchange";
exchange.shows = "bourse.exchanges.name";      // the exchange by name, not by slot
lusine::core::Widget price = name;
price.quantity = "bourse.listings.price";      // its heading reads "price (EUR)"

Sound needs no C++: if the program writes lusine.sound's block of samples, the engine sizes it for one step and plays it. The program writes it in Trait: each step it appends a partial for each body its contacts strike, a rumble for each rolling contact and the partials of the notes it starts (one join of its note-on rows with each instrument pack's partials), releases the notes it ends, sets the ambience, and calls lusine.sound.synthesise(). The header of library/lusine/sound/sound.trait writes that wiring out, with what your contacts', rollings', materials' and notes' rows hold, and the conventions an instrument pack follows; tests/trait/sound_wiring.trait is a game wired so. How loud each sound is, and which actions are silent and why, are your rows of lusine.sound.gain (library/lusine/sound/gains.trait).

Building and running

cmake --preset linux-debug           # or vs2026-debug, macos-debug
cmake --build --preset linux-debug
./build-linux-debug/mygame

Every game takes these options:

Option What it does
--size WxH each lattice's size, a comma between lattices
--fill COLUMN=DENSITY fill a column of true or false at random, this share true
--seed N the random fill's seed
--frames N stop after N steps
--headless no window: compute only, as fast as the device runs (needs --frames)
--control no window: take commands from tools on standard input and output
--load NAME start from the world saved as NAME; --frames N then runs N steps more
--save NAME save the world as NAME when the run ends
--tuning DIR read tuning.json and user/tuning.json from this folder
--timings FILE write each step's time on the device to this file at the end
--help list the options, your own among them

In the window, Space pauses and resumes, the right arrow runs one step while paused, F1 opens the engine's settings, and Escape quits.

Keys and the mouse

Every other key, and the mouse over your viewports, is your game's. The engine appends each event to one of lusine's input tables before the step it belongs to, in the order it came, and your Trait reads them:

  • lusine.input.keys: a key's code (where it is on the keyboard, as its USB HID usage: 4 is A, 26 is W, 44 is Space, 79 to 82 the arrows, so W A S D are the same keys on any layout), its action (0 released, 1 pressed, 2 repeating) and its modifiers (1 Shift, 2 Ctrl, 4 Alt, 8 Meta);
  • lusine.input.pointers: the mouse's down, moves, up and scroll (phase 0 down, 1 move, 2 up, 4 scroll), its buttons (1 left, 2 right, 4 middle), the viewport it is over (its index in game.viewports) and at, where in that viewport from 0 to 1, from its top left. A drag stays with the viewport it began in. pressure is 0.5 while a button is down; tilt is 0.

Each row lives through the step that took it. Call lusine.keyboard.track(); at the start of your main and lusine.keyboard.held[26].down says whether W is held. Turning at into a place in your world is your own kernel, through your camera.

A game whose program reads lusine.input.keys takes Space and the right arrow too: the engine then has no pause or step key. Escape and the F-keys always stay the engine's. While the mouse is over a panel, or you type in one, your game is sent nothing; and when the window loses focus, every key held is released.

Running with no window

./build-linux-debug/mygame --headless --frames 600

runs 600 steps with no window and no display, which works on a machine with no screen (and, with Mesa's lavapipe, with no graphics card). The exit code says how it went: 0 the run ended as asked, 64 the command line was refused, 65 the starting state was refused, 69 no device, 70 a step raised an error or the game's own check failed. The build's mygame_run test is such a run, and ctest runs it.

A run with a window keeps the graphics driver's compiled shaders in user/cache/pipelines-<vendor>-<device>.bin beside the executable, so its second start is fast. A run with no window (--headless, --control, the test suites and lusine's own runners) starts from an empty cache and never reads or writes that file: such runs are started by the hundred from one build folder, where a shared file only grew and slowed every start. The cache changes no result, only how long a start takes.

To ship the game, see Building a game. To make the same game run in a browser, see A web game.