Skip to content

Building a game on lusine

What a game's build gets from lusine, and how to prove it works. A game carries none of it as a copy: lusine is the game's submodule, and the game's build is a few lines that name lusine's. The Trait compiler, which comes with lusine, writes the game's runtime unit at build, and lusine's core (src/core/) runs it.

A game's build files

A game's CMakeLists.txt, the Life example game's, in the short form:

cmake_minimum_required(VERSION 3.28)
project(life LANGUAGES CXX)
add_subdirectory(lusine)

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

add_subdirectory(lusine) makes lusine's functions available:

  • lusine_game_executable(<name> SOURCES <file.cpp>... TRAIT <file.trait>... [UNIT <unit>] [RUN_FRAMES <n>] [NO_RUN_TEST] [LICENCE <file>] [DATA <dir>...]) (cmake/LusineGame.cmake) is what every game's build repeats, in one call: the unit (lusine_trait_unit(<unit> SOURCES <file.trait>...), <unit> the name unless given), the executable linking lusine and <unit>_unit with LUSINE_GAME_SPV defined, lusine_mp_units_fmt() and lusine_executable_sanitize(<name>), the game directory beside the executable, Tracy's client copied beside the executable where it is a shared library, the test <name>_run: <name> --headless --frames <n> (60 unless RUN_FRAMES), timeout 600, in the executable's directory, a device's test (unlabelled; NO_RUN_TEST registers none), and the ship zip's target <name>_package (below, "Shipping a game"). A bad argument is refused at configure, naming the function. A game adds its own sources, libraries and tests to the target after it, and a game with more than one executable calls it once for each (or uses the functions below).
  • lusine_trait_unit(<name> SOURCES <file.trait>...) (cmake/LusineTrait.cmake) runs the Trait compiler at build and compiles the unit's shaders with glslc for Vulkan 1.3 (--target-env=vulkan1.3 -fshader-stage=comp -O, the version the engine requires of a device), under <build>/trait/<name>/, and makes <name>_unit, which a target links to have the unit's program.h on its include path and its SPIR-V directory as LUSINE_TRAIT_<NAME>_SPV.
  • lusine_mp_units_fmt() (cmake/LusineMpUnits.cmake): a target linking mp-units formats through fmt.
  • lusine_executable_sanitize(<target>): the sanitizers lusine's own targets build with.

Every shader a build compiles (each unit's, the library kernels a game's Trait uses among them, and the table view's) carries glslc -g debug information, source names and lines for RenderDoc or Nsight, in a Debug configuration (*-debug presets; per configuration under Visual Studio). -D LUSINE_SHADER_DEBUG=ON adds it in every configuration; the ship presets never carry it, and refuse the option. -g adds no instruction: the shaders are the same -O code either way, only larger.

C++20 comes with lusine (target_compile_features(lusine PUBLIC cxx_std_20)). A game that builds its executable with these functions rather than lusine_game_executable copies Tracy's client beside it on Windows itself, and registers its own tests (add_test).

python lusine/tools/lusine/init.py --name <name>, from a new game's root, writes the starters for all of this, CMakeLists.txt in the short form above among them (tools/lusine/templates/game/; Your first game walks through it).

A game's CMakePresets.json:

{
  "version": 8,
  "include": ["lusine/cmake/GamePresets.json"]
}

A game has no vcpkg.json (the toolchain generates one, below) and no lusine.toml: it is its CMakeLists.txt, which names it (lusine_game_executable(<name> ...)) and, optionally, its vcpkg ports, its trait.toml and its sources.

.gitignore in a game holds /generated/, /build/, /build-*/ (the presets' build-ship/, build-linux-debug/ and the rest) and /vcpkg/.

The game's own vcpkg ports

set(LUSINE_VCPKG_PORTS glm "imgui[docking-experimental,freetype,glfw-binding,vulkan-binding]")
project(mygame LANGUAGES CXX)

LUSINE_VCPKG_PORTS names the game's vcpkg ports beyond lusine's own, in vcpkg's port[feature,...] spelling, set in the game's CMakeLists.txt before project() (or in a preset's cacheVariables): the toolchain runs at project(), when vcpkg installs, so the ports must be known by then. It is optional. tools/lusine/vcpkg_manifest.py refuses a spelling that is no port's.

Deliberately not here: the Vulkan SDK version (lusine's, below), the executable and its sources (lusine_game_executable), and Python packages (a requirements file).

Shipping a game

A game ships with one command, from its root:

cmake --workflow --preset vs2026-ship   # Windows: build-ship/<game>-<version>-win64.zip
cmake --workflow --preset linux-ship    # Linux:   build-linux-ship/<game>-<version>-linux-x64.zip
cmake --workflow --preset macos-ship    # macOS:   build-macos-ship/<game>-<version>-macos-arm64.zip

each the ship preset configured and its *-ship-package build preset built, which builds package, and so every <name>_package that lusine_game_executable made. A game declares nothing for it: the target, the layout and the check are lusine's.

The game directory is the build's output, beside the executable, rebuilt whole at every link (cmake/LusineGameDirectory.cmake): generated/shaders/ the unit's SPIR-V and data/<dir> each DATA directory as itself. It is <name>/ on Windows and <name>_data/ on Linux and macOS, where a directory cannot share the executable's name. A game's main reads its shaders from there, run.spirv = LUSINE_GAME_SPV; (the relative path, which RunGame resolves beside the executable), so a run in the build and a run of the zip read the same files; LUSINE_TRAIT_<UNIT>_SPV, the build tree's absolute directory, is for tests, and a zip whose executable names it is refused.

The zip, <build>/<name>-<version>-<platform>.zip (cmake/LusineGamePackage.cmake), holds one directory of its stem with: the executable; the shared libraries it needs that the system does not carry (file(GET_RUNTIME_DEPENDENCIES), beside it first; on Windows the MSVC runtime too, and the executable finds them beside it, $ORIGIN or @executable_path on its run path elsewhere); the game directory; and the licence file, LICENCE <file> or else the first of LICENCE, LICENSE (.md, .txt) in the game's root. <version> is git describe --tags --always --dirty of the game's root when the zip is made, and <platform> win64, linux-<arch> or macos-<arch>. A library the executable needs and nothing holds refuses the package by name.

The check, tools/lusine/ship_check.py, runs on the zip and removes it when it refuses: the contents exactly the above; the executable not naming the build tree's SPIR-V; every import of the executable and its libraries in the zip or on the platform's fixed system list (dumpbin /dependents on Windows, readelf -d on Linux, otool -L on macOS), with a control that removes one and must be refused; and the unzipped game run with its run test's arguments, --headless --frames <n>, on Windows with PATH cut to the Windows directories, elsewhere with the loader's search variables removed. The run is an engine run on the machine's GPU, so the package step takes the machine's claim where several sessions share it. It does not check glibc's or libstdc++'s symbol versions, so a Linux zip is good for a distribution at least as new as the one that built it.

The presets and the toolchain

cmake/GamePresets.json holds the configure presets a game includes: vs2026-debug, vs2026-release, linux-debug, linux-release, macos-debug, macos-release, and the ship presets vs2026-ship, linux-ship and macos-ship (LUSINE_SHIP, Release, every instrument absent), each with a *-ship-package build preset that builds package and a workflow preset of its name that runs the two ("Shipping a game", below). ${sourceDir} is the game's root, and each names lusine's toolchain, lusine/cmake/vcpkgToolchain.cmake.

The toolchain runs before project(). When lusine's parent directory holds a CMakeLists.txt calling lusine_game_executable, it is building a game, and two things differ from lusine built alone:

  • Where vcpkg is found. The game's own vcpkg/, then $VCPKG_ROOT, then the game's main checkout's vcpkg/. A game's main checkout with none of these clones one into itself; a worktree refuses rather than cloning a second. Inside a game, lusine's own "main checkout" would be the superproject's .git/modules, never a checkout, which is why the search is the game's.
  • The game's vcpkg.json is generated, never carried: tools/lusine/vcpkg_manifest.py writes <build>/vcpkg-manifest/vcpkg.json from lusine's vcpkg.json plus the game's LUSINE_VCPKG_PORTS, and VCPKG_MANIFEST_DIR points at it. A port both name is listed once, with the union of its features. The name is the game's; the baseline is always lusine's, so a game cannot move the versions lusine is built and tested against. An unchanged manifest is not rewritten, so vcpkg's install step does not rerun.
Port For
vulkan the loader and headers (vulkan-headers, which carries vulkan.hpp)
vulkan-utility-libraries vk_enum_string_helper.h: a device failure's result slot names the VkResult (VK_ERROR_OUT_OF_DEVICE_MEMORY)
glfw3 the window and its surface
stb the save container's deflate (stb_image's zlib); the engine decodes no image
miniaudio sound: playback and mixing (header-only; compiled once in src/audio/miniaudioImpl.cpp)
vulkan-memory-allocator-hpp VMA
spirv-reflect, spirv-headers a shader's bindings and push block, read from its SPIR-V
nlohmann-json the save's layout section, a program's catalog (src/core/catalog.h)
spdlog the log
imgui, implot the declared panels
tracy per-pass GPU zones, where LUSINE_ENABLE_TRACY

The Vulkan SDK pin

At configure, lusine's own and every game's, cmake/LusineBase.cmake requires:

  • a Vulkan SDK of at least LUSINE_VULKAN_SDK_MINIMUM (1.4.357 now);
  • glslc, which compiles every shader (its own -O optimises them; no spirv-opt runs).

Each is refused by name, for example:

lusine: the Vulkan SDK is 1.4.357; lusine needs 1.4.999 or later

A passing configure says so:

-- lusine: Vulkan SDK 1.4.357 (at least 1.4.357); glslc at C:/VulkanSDK/1.4.357.0/Bin/glslc.exe

Running without a GPU: lavapipe

Mesa's lavapipe is a Vulkan device on the CPU. On it the C++ core runs the real SPIR-V, so the gpu suites, a game and the corpus run on a machine with no GPU: CI's Linux docker job, a cloud session, a laptop. It is slow, a few times to a hundred times slower than a GPU, and multi-threaded, so races and memory ordering still show.

  • Installing it. On Ubuntu 24.04, sudo apt-get install mesa-vulkan-drivers (Mesa 25.2, Vulkan 1.4). The docker runners' image has it (the shared CI workflows' runner/Dockerfile). On Windows and macOS there is no packaged lavapipe; a GPU is used there.
  • Choosing it. The engine tries the devices in this order: the discrete GPUs, the integrated ones, the virtual and the rest, and a CPU device last, so lavapipe is used only where nothing else is (vulkan/deviceChoice.h, DeviceOrder). LUSINE_DEVICE=cpu asks for a CPU device first; any other value than cpu or any refuses the run by name. The loader can also be held to lavapipe alone:
VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json ctest --test-dir build-linux-debug -C Debug -L gpu --output-on-failure
  • What it supports. Every subgroup operation the shaders declare (basic, vote, arithmetic, ballot, clustered), at a subgroup size of 8. trait's results do not depend on the subgroup size.
  • CI. The compile job runs ctest -L nogpu, then ctest -L gpu on lavapipe, in .github/workflows/ci.yml.

Proving a change to the build: the fresh clone

A change to LusineBase.cmake, LusineTrait.cmake, the toolchain or the presets is proved on a game, from a fresh clone with nothing beside it. A checkout that has built before hides what a fresh one lacks: a stale copy, a cache, a sibling's files:

  1. Push the lusine branch, and a game branch whose lusine submodule is pinned to it.
  2. Clone the game's branch into a directory no one else uses, with --recurse-submodules (the Trait compiler inside lusine with it), and nothing beside it. VCPKG_ROOT names a vcpkg if the clone has none of its own.
  3. cmake --preset vs2026-debug, then cmake --build --preset vs2026-debug. Check that build/vcpkg-manifest/vcpkg.json names the game and that the game holds no vcpkg.json.
  4. ctest --test-dir build -C Debug -j <cores>: the game's tests and lusine's suites pass, and more than zero tests ran ("No tests were found" is a failure, not a pass).
  5. For the SDK pin, plant a failure (a minimum above the installed SDK) and require the refusal by name before believing the pass.

The lusine side is then built alone as well: configure with -DBUILD_TESTING=ON, build, ctest, and python tools/gates.py ci --diff-base origin/main.