Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome!

Hello there! Thank you for checking out Kerosene.

Note

AI Generated content disclosure: Most of these docs were made by AI. If you want to improve them, please do! All help to kerosene is greatly appreciated :>

Warning

Kerosene is 1.0.0 alpha. It works end to end, but its API may still change between alphas, and a finished game still needs things it does not have yet: see Missing features.

Hello! Welcome to the Kerosene documentation.

Have you ever used the Source engine? Have you wished it was open source and you could make a game with the same tools? With Kerosene, we’ve tried our best!

Kerosene is a Rust game crate for brush-built 3D games. You add kerosene to a Cargo project, implement one trait, and you have a game, with the editor and every compiler along with it. Everything builds from source on your own machine, so the same game builds on Linux, Windows and macOS. It is PURELY open source: GPL 3.0 with an exception that lets your game stay yours, so long as it says it is built with Kerosene.

cargo install kerosene --features tools
kerosene-tools new mygame
cd mygame
cargo play

Kerosene has, among other things:

Tip

This list is incomplete, please read the rest of the docs to get a full view of this engine’s capabilites.

  • A Source-style movement solver, air-strafing and all
  • Brush-based world geometry, compiled to a BSP tree with baked visibility, lighting and acoustics
  • Physics props on Box3D
  • Entity I/O, Rhai map scripting and a game UI
  • Skeletal animation
  • Saved games and level changes
  • Steam: achievements, stats, leaderboards, cloud saves and the Workshop
  • Chisel, a level editor, and a compiler for every kind of content
  • And more!

Making a game? Start with Getting started, then Making a game, and read Publishing before you hand anything to anyone.

Working on Kerosene itself? The Documentation and the Devnotes are how it works inside.

Getting started

Kerosene is a crate. A game is a Cargo package that depends on it, and everything — the engine, the editor, the compilers, your game — builds from source on your own machine. There is no installer, no launcher and no account. What runs on Linux runs on Windows and macOS, because it is the same code compiled there.

What you need

  • Rust 1.94 or later, from rustup.
  • On Linux, the ALSA headers for sound: libasound2-dev on Debian and Ubuntu, alsa-lib-devel on Fedora. Without them, a game still builds with --no-default-features, just silently.
  • A GPU with Vulkan, Metal or DirectX 12. The editor’s 3D view is drawn in software and needs none.

Five minutes to a running game

cargo install kerosene --features tools
kerosene-tools new orbital-drift --name "Orbital Drift"
cd orbital-drift
cargo play

The toolset can also come straight from the repository, cargo install --git https://github.com/t342guy/kerosene kerosene-tools, and a game made by that toolset depends on the matching release tag, so it builds the same Kerosene the toolset is. kerosene-tools doctor checks the machine first: Rust, the Linux sound headers, the GPU.

new makes this:

orbital-drift/
  Cargo.toml              the package: kerosene, and two binaries
  .cargo/config.toml      cargo play, cargo tools, cargo ship
  orbital-drift.kproj  the project: its name, content, start map, game
  README.md               the commands, and what is where
  .gitignore              the build's outputs, and the player's own files
  .gitattributes          art and sound are binary
  .github/workflows/ci.yml  test and play it on Linux, Windows and macOS
  src/
    main.rs               the game binary: launch(game, options)
    game.rs               the game: a Game, a class of its own, and a test
    tools.rs              the game's own editor and compilers
  content/
    maps/orbital_drift_start.kmap
    art/ materials/ models/ sound/ scripts/ textures/

It is also a git repository, unless it was made inside one already or with --no-git.

The first cargo play builds the engine and its tools, which takes a few minutes. Then it compiles the starter map and opens the game on it. Walk up to the orange plinth: the gem on it is an item_pickup, a class the game defines in src/game.rs, and the console (`) says so when you take it. F5 saves, F9 loads.

Every cargo play after that rebuilds only what changed. With nothing changed, the game starts at once.

The loop

CommandDoes
cargo playBuild changed content, build the game, run it on the start map
cargo play +map other…on another map. Anything after play goes to the game.
cargo play --fullBuild maps with full visibility and lighting first
cargo play --watchKeep building while the game runs: save a map in the editor and the game reloads it where you stand
cargo toolsThe toolset window: project, editor, sound editor, build, archive
cargo tools chisel content/maps/x.kmapThe editor, on a map. F9 in it compiles the map and plays it.
cargo shipBuild everything properly and assemble dist/, ready to hand out
cargo runThe game on its own, as a player gets it: the main menu first, with the content as it is
cargo testThe game’s own tests: game.rs starts with one
cargo tools cleanDelete what the build wrote, to build it all again

cargo play skimps the maps’ visibility and lighting, because a layout you are still moving walls around in does not need them. cargo ship always does them properly.

Where things go

Your code is src/game.rs and whatever modules you add. A game is one type implementing Game; every method has a default, so you add the ones you need. Making a game goes through each.

Your content is content/. Maps are drawn in Chisel. Textures go in art/ as PNGs; models in art/ as OBJ or glTF; sounds in sound/ as WAV, FLAC or MP3. cargo play compiles each into what the engine reads, beside its source.

The engine’s base content is under all of it. Kerosene carries the developer textures, the stock props, sounds and UI, and a plain room, compiled into the engine, so a game has something to show before it has anything of its own. Put a file at the same path in content/ and yours is used instead: content/ui/hud.kui replaces the stock HUD.

Another platform

Build on it. Clone your game on the Windows or macOS machine and run the same three commands; nothing in a Kerosene game is specific to where it was started. cargo ship there makes that platform’s dist/.

kiln --ship dist --target <triple> cross-compiles, for a developer who has set up that target’s linker and libraries. That setup is Rust’s rather than Kerosene’s, and building on each platform is the supported way.

Staying up to date

Kerosene follows Semantic Versioning on the kerosene crate’s API. Your Cargo.toml names a version; cargo update takes fixes and additions without breaking your game, and a new major version is one you move to on purpose, with the changelog saying what to change.

Next

Making a game

This section is for people who want to build something on Kerosene, as opposed to people working on Kerosene itself. The rest of the book describes the engine from the inside; these pages describe it from where a game developer stands: what a project is, what it looks like on disk, how it gets built, and what has to be true before you hand it to somebody.

Warning

Kerosene is 1.0.0 alpha. Everything described here works, but the list of things a finished game needs and the engine does not yet have is long — see Publishing and Missing features before you commit to it.

New here? Getting started has you playing a game of your own in five minutes; this page is what is going on underneath.

Two shapes of project

A Kerosene project is a directory with a .kproj file at the top and a content tree beside it. There are two kinds, and the difference is one key.

A game crate. Your own Rust binary, depending on kerosene, with your own entity classes and rules: what kerosene-tools new makes. The engine is a dependency, never a fork: your crate implements one trait, Game, and hands it to launch; nothing in the engine’s source is edited to add a mechanic. A game crate below is the whole of it, and the stock kerosene runtime is the same thing with the stock game: apps/kerosene/src/main.rs is five lines.

A content-only project, or a mod, in Source’s sense: maps, materials, models, sounds and scripts, and nothing compiled. The game binary is the stock kerosene runtime, unmodified, and all of the behaviour comes from entity I/O wired in Chisel and from Rhai scripts (see Scripting), using the entity classes that ship in kerosene-game. No Rust and no build of the engine — but also nothing the stock classes cannot do, because Rhai is deliberately sandboxed and cannot allocate an entity, touch the renderer or open a file. kerosene-tools new --content-only makes one.

The key that turns one into the other is game.

The project file

project
{
    "name"     "My Game"
    "content"  "content"
    "startmap" "mg_intro"
    "game"     "my-game"
}
KeyMeansIf absent
nameWhat the title bar and the shipped README.txt call itThe file’s own name
contentThe content tree, relative to the project filecontent/ beside the file, then the file’s directory
startmapThe map New Game loads from the main menu, and that -nomenu, play and a headless run start onThe base content’s room
gameThe Cargo package whose binary is the game: built and launched by F9, built and copied by kiln --shipThe stock kerosene runtime runs and ships instead
binThat package’s binary, when it is not named after the packageThe package name
dirRepeatable: the directories the content tree is made of, when the standard set is not wantedThe standard set below

content is relative so the project can be cloned or moved and still be right. Every tool and the engine find the project the same way, with the same code, so there is one answer to “where is the content” and each program says which answer it took. The full account is in crates/kerosene-vfs/src/project.rs.

The content tree

content/
  engine.kcfg      renderer, window size, vsync — written on first run
  art/                source textures (.png) and meshes (.obj)
  textures/           texture *sets*: colour, normal, roughness… per folder
  materials/          .kmat — hand-written KeyValues (built into .kmat_c)
  models/             .kmdl, compiled by Forge from art/
  maps/               .kmap (yours) and .kbsp (compiled)
  sound/              .wav / .flac / .mp3 sources and compiled .kaud
  scripts/            .kscr (Rhai) and the .ksnd sound table
  <name>.vault        the archive Kiln packs everything into

Sources and build outputs sit side by side on purpose: every .obj under art/ becomes a .kmdl at the matching path under models/, and every .kmap under maps/ becomes a .kbsp. There is no list to keep up to date. Commit the sources; the compiled files are outputs and reproducible.

engine.kcfg always exists once anything has run — the first program to look for it and not find it writes the defaults. See Configuration.

The loop

flowchart LR
    C[Chisel<br/>edit the map] -->|F9| K[Kiln<br/>textures · models · sounds · maps · pack]
    K --> R[kerosene<br/>play it]
    R -->|what's wrong| C
    K -->|"--ship dist"| D[dist/<br/>hand it to someone]

Day to day, in a game crate:

cargo play                            # build what changed, then play
cargo tools chisel content/maps/mg_intro.kmap
cargo ship                            # build it all properly, into dist/

and in a content-only project, the same with the toolset itself: kerosene-tools play, kerosene-tools chisel …, kerosene-tools kiln --ship dist.

Chisel’s F9 compiles the current map and launches the engine on it, and builds any textures added since the editor opened. kiln --only maps --fast skips the visibility and lighting passes while a layout is still moving; an unvised, unlit map loads and plays, it just draws everything and looks flat.

Everything Kiln does is also a subcommand you can run by hand or from a build server — cleave, umbra, resonance, radiance, alchemy, forge, timbre, vault — and Tools is the reference for each.

When the game is ready for other people, the last stage is kiln --ship <dir>, and Publishing is about what that does and what it cannot do for you.

A game crate

The engine is one crate, kerosene, and a game is a crate that depends on it. Nothing else of the engine is named; the engine’s public parts are modules of it (kerosene::engine, kerosene::entity, kerosene::math, …), and so are the third-party crates your own signatures mention — kerosene::egui, kerosene::glam, kerosene::rhai — so the versions can never disagree. Its version follows SemVer on exactly that API; see Versioning for what is covered, and what, under kerosene::internals, is not.

You need Rust 1.94 or later (the workspace is edition 2024). On Linux the default audio feature wants the ALSA headers (libasound2-dev); --no-default-features builds without a sound device.

kerosene-tools new writes all of the following; this is what it means.

[package]
name = "mygame"
version = "0.1.0"
edition = "2024"

[dependencies]
kerosene = { version = "1.0.0-a4", default-features = false }

[features]
default = ["audio"]
audio = ["kerosene/audio"]
tools = ["kerosene/tools"]
steam = ["kerosene/steam"]

[[bin]]
name = "mygame"
path = "src/main.rs"

[[bin]]
name = "mygame-tools"
path = "src/tools.rs"
required-features = ["tools"]

# The engine optimised even in a debug build.
[profile.dev.package."*"]
opt-level = 2

The engine’s default features are off and passed through as the game’s own, so cargo build --no-default-features reaches the engine’s audio switch. required-features is what keeps the toolset — egui, the compilers, the audio decoders Timbre uses — out of a plain cargo build of the game. Features are per package, not per binary, so without it the game binary would link everything the tools do.

The game

A game is a type that implements Game. Every method has a do-nothing default, so a game that only adds a class implements one method. This one adds an item_pickup class, an inventory, an additem command and a HUD line:

#![allow(unused)]
fn main() {
// src/game.rs
use kerosene::prelude::*;

pub const SCHEMA: &str = include_str!("mygame.kdef");

#[derive(Default)]
pub struct MyGame {
    inventory: Vec<String>,
}

// Class handlers get the entity world and nothing else, exactly as the
// stock ones do, so they can be tested without an engine. When one needs
// the rest of the game it leaves a request with a kind of the game's own;
// `entity_request` below picks it up at the end of the tick.
fn input_pickup(world: &mut EntityWorld, id: EntityId, ev: &kerosene::entity::InputEvent) -> bool {
    let what = world
        .get(id)
        .and_then(|e| e.fields.text("item").map(|t| t.into_owned()))
        .unwrap_or_else(|| "thing".into());
    world.request("mygame.pickup", what, id, ev.activator);
    world.fire_output(id, "OnPickup", ev.activator, None);
    world.remove(id);
    true
}

impl Game for MyGame {
    fn classes(&self, registry: &mut ClassRegistry) {
        kerosene::game::register(registry); // doors, triggers, lights: the stock set
        registry.register(
            ClassDef::new("item_pickup")
                .input("Pickup", input_pickup)
                .output("OnPickup"),
        );
    }

    fn schema(&self) -> &'static str {
        SCHEMA
    }

    // The console is up, nothing has run yet: register commands here and
    // they work from autoexec.cfg and the command line.
    fn setup(&mut self, engine: &mut Engine) {
        engine.console.register_command("additem", ConVarFlags::NONE, "Put an item in the inventory.", |con, args| {
            con.request("mygame.additem", args.get(1).unwrap_or("thing"));
        });
    }

    fn entity_request(&mut self, engine: &mut Engine, req: &HostRequest) -> bool {
        if req.kind == "mygame.pickup" {
            self.inventory.push(req.payload.clone());
            engine.console.print(format!("picked up {}", req.payload));
            return true;
        }
        false
    }

    fn console_request(&mut self, _: &mut Engine, kind: &str, payload: &str) -> bool {
        if kind == "mygame.additem" {
            self.inventory.push(payload.to_string());
            return true;
        }
        false
    }

    // Every tick, after the player has moved and entities have thought,
    // before physics steps. `engine` is the whole engine.
    fn tick(&mut self, engine: &mut Engine, _input: &InputState, _dt: f32) {
        if engine.player.health <= 0.0 {
            self.inventory.clear();
        }
    }

    fn wants_ui(&self) -> bool {
        true
    }

    fn ui(&mut self, _: &mut Engine, ctx: &egui::Context) {
        egui::Area::new("hud".into()).show(ctx, |ui| {
            ui.label(format!("inventory: {}", self.inventory.join(", ")));
        });
    }
}
}
// src/main.rs
mod game;

// Counts allocations for the `mem` console command.
#[global_allocator]
static ALLOCATOR: kerosene::lifecycle::TrackingAllocator =
    kerosene::lifecycle::TrackingAllocator::new();

fn main() -> kerosene::anyhow::Result<()> {
    kerosene::launch(
        game::MyGame::default(),
        kerosene::LaunchOptions::new("My Game", env!("CARGO_PKG_VERSION")),
    )
}

launch is everything the stock binary does: the logger, --content, --vault, --headless, +command arguments, finding the project file and its start map, the saved engine settings, every .vault in the tree, and then a window or a headless run. mygame --headless 600 +map mg_intro is your dedicated server. The name is the window’s title and the desktop’s name for the game (.app_id(..) to choose another), and the version — yours, not Kerosene’s — is what version in the console and the log print beside Kerosene’s own.

The hooks, in the order the engine calls them: classes (once, at construction), setup, map_loaded and player_spawned, then per tick pre_tick (before the player moves — change movement convars here), tick (after entities have thought), with entity_request and console_request answering anything the engine did not understand; each frame frame (paused or not, with the real time elapsed) and ui while wants_ui says so. map_unloading comes before a map is replaced and shutdown as the engine exits. save and load keep the game’s own state in saved games and carry it across level changes, and can_save can refuse one; see Saving and level changes. player_damaged decides how much a hit takes, and player_died can take a death over – a death screen, reloading the last save – instead of the instant respawn: return true and the player stays down until engine.respawn_player(). One rule: a hook must not make the engine call another hook on the same game, so tick uses engine.request_map(..) rather than load_map (the inner hook would be skipped and logged, not run). Everything else on &mut Engine is yours: the fields console, entities, player, ui, audio, platform and input, and methods such as vfs(), time(), map_name(), run_script(..) and quit().

What a game most often reaches for:

engine.trace_view((0.0, 0.0), 4096.0)What the player is looking at: a TraceHit with the point, the surface’s normal and the entity, if it was one
engine.trace(start, end, mins, maxs, mask)Any line or box, against the world, the moving brushes and the props
engine.spawn_entity("npc_guard", &[("origin", "0 0 16")])An entity of any class, with keyvalues, as if the map had it
engine.find_path(from, to)Waypoints over the map’s walkable floor
engine.rng()Dice seeded by the map and kept in saves: replays and bug reports repeat
engine.view_punch(..), screen_shake(..), set_fov_override(..)Recoil, an explosion, a scope
engine.set_view_angles(..), set_camera(Some(..))Where the player looks; a cutscene or death camera
engine.debug_line(a, b, color, seconds), debug_box, debug_pointSeeing what your code is doing, while r_debugdraw is on
engine.set_player_max_health(..)What the player spawns with
engine.damage_entity(id, amount, attacker)Hurt an entity: its class’s on_damage decides what that means, and it fires OnDamaged
engine.teleport_player(origin, angles), kill_player(reason)Move the player; kill them, god mode or not
engine.new_game(), end_game()Start on the start map; back to the main menu

A class of the game’s own with a model is drawn and collided with when it says how: ClassDef::new("npc_guard").model(ModelRole::Animated).

A class can react to the player walking into it, and to being damaged, without any wiring: ClassDef::new("item_ammo").on_touch(give_ammo) runs give_ammo(world, id, player) on the tick the player’s box meets it (its brush, or for a point entity a cube touch_size across), and .on_damage(f) runs f(world, id, amount, attacker) whenever engine.damage_entity hits it. The stock weapons call damage_entity on what they shoot, which is how breakable glass breaks.

Input reaches a game the way it reaches the engine: as console commands, bound to keys. A game’s own held action needs no command of its own: bind a key to +zoom and read engine.input.action_held("zoom") in tick. For a one-shot, register a command in setup and bind it in cfg/autoexec.cfg. engine.input is also what a rebinding screen changes: bind, unbind and keys_for("+jump").

The schema

Chisel shows what a .kdef says. Yours describes your classes and can inherit the engine’s bases:

// src/mygame.kdef
class {
    "name" "item_pickup" "base" "Entity" "base" "Point"
    "help" "Something the player can pick up."
    key    { "name" "item" "label" "Item" "type" "string" "default" "thing" "help" "What it is." }
    input  { "name" "Pickup" "help" "Take it." }
    output { "name" "OnPickup" "help" "Fired when taken." }
}

Entity carries the inputs every entity answers (Kill, AddOutput, the FireUsers); Point carries origin and angles. The registry and the schema are checked against each other by the same function the stock game uses for its own, so a class you register and forget to describe — or an input you describe and forget to handle — is a failing test and not a designer wiring up nothing:

#![allow(unused)]
fn main() {
// tests/schema.rs
#[path = "../src/game.rs"]
mod game;
use kerosene::prelude::*;

#[test]
fn the_schema_describes_the_game() {
    let mut registry = ClassRegistry::new();
    game::MyGame::default().classes(&mut registry);
    let mut schema = kerosene::entity::Schema::parse(kerosene::game::schema::BUILTIN).unwrap();
    schema.merge(kerosene::entity::Schema::parse_after(game::SCHEMA, Some(&schema)).unwrap());
    let problems = kerosene::entity::schema::check(&registry, &schema);
    assert!(problems.is_empty(), "{problems:#?}");
}
}

The tools

The toolset is a library behind the tools feature. Your mygame-tools is the whole of it — Chisel, the compilers, Kiln, Vault — with your schema in the editor and F9 building and launching your binary:

// src/tools.rs
mod game;

fn main() -> kerosene::anyhow::Result<()> {
    kerosene::tools::main_with(
        kerosene::tools::Options::new("mygame-tools", env!("CARGO_PKG_VERSION"))
            .schema(&[game::SCHEMA])
            .game("mygame"),
    )
}
cargo run --features tools --bin mygame-tools -- chisel content/maps/mg_intro.kmap

which .cargo/config.toml shortens to cargo tools chisel …; cargo play is mygame-tools play, and cargo ship is mygame-tools kiln --ship dist.

F9 runs cargo build -p mygame (a debug build, for the edit-compile-play loop) and launches target/debug/mygame on the compiled map, with cargo’s output in the editor’s output panel. kiln --ship builds it in release. The stock kerosene-tools does the same for any project whose file names a game package, without your schema in the editor — drop the .kdef into the content tree for that. A package whose binary is not named after it says so with "bin" in the project file.

  • Formats — every file above, byte by byte.
  • Scripting — the whole Rhai surface on one page.
  • Audio — the .ksnd table and how sounds are placed.
  • Positioning — what the engine is shaped to be good at, which is the honest way to decide whether to use it.

Saving and level changes

Kerosene saves the whole game between two ticks and puts it back exactly: the same entities with the same handles, the same inputs still on their way, the player where they stood and moving as they were. A saved game played on for two hundred ticks ends in the same world as the game that was saved played on for two hundred ticks; a test holds it to that.

Most games need no code for any of it. A game crate adds two methods if it keeps state of its own.

For players

Key or commandDoes
F5, quicksaveSave as quick
F9, quickloadLoad quick
save <name>Save under a name: letters, digits, _, - and .
load <name>Load one
savesList them, newest first

Saves are files, save/<name>.ksav, in the player’s own directory: the same place cfg/config.cfg goes.

PlatformDirectory
Linux~/.local/share/<game> (or $XDG_DATA_HOME/<game>)
Windows%APPDATA%\<game>
macOS~/Library/Application Support/<game>

<game> is the game’s app id, from its name (orbital-drift). Not beside the executable, which a game installed in Program Files or a Steam library cannot write to. --portable keeps them in the first content directory instead, which is where saves from before 1.0.0-a3 are, and those are still found and loaded either way. They are JSON, so a bug report can include one and anyone can read it.

A game cannot be saved with no map loaded, or while the player is dead.

For mappers

Three entities:

ClassDoes
logic_autosaveA checkpoint. Its Save input saves under savename (auto by default). Wire a trigger_once to it.
trigger_changelevelWalk in, and the player moves to map, keeping their health and whatever the game carries. Also has a ChangeLevel input, for a level change something else decides on.
info_landmarkA named point the two maps share.

Landmarks. Put an info_landmark with the same name at the same spot, relative to the geometry either side of the seam, in both maps, and name it in the trigger_changelevel’s landmark. The player arrives exactly where they stood relative to it, moving and facing as they were, so a corridor that crosses the seam is walked straight through. Without one, they start at the next map’s info_player_start.

With sv_autosave 1, the default, every level change saves as auto on arrival.

From the console or a script: changelevel <map> [landmark].

What a save keeps

Everything in an entity’s fields, wires and I/O queue, which is everything the stock classes know:

  • a door half open stays half open, and finishes opening
  • a math_counter keeps its count
  • a wire that fires once, and has, stays spent
  • a logic_auto does not fire again
  • an output fired with a delay still arrives on time

It also keeps:

  • the player, with their velocity and view
  • each physics prop’s velocity
  • the placed decals
  • the UI store and the layers that were showing
  • the map script’s variables, meaning every top-level variable that holds plain data: numbers, text, booleans, arrays and maps

A variable holding a function or an engine object is not saved. It comes back as whatever the script’s top level sets it to.

A save of a map that has since been recompiled still loads, with a warning, because the entities it holds may no longer match the geometry.

For game code

The engine owns the level. Your Game owns everything else, and saves it with two methods:

#![allow(unused)]
fn main() {
impl Game for MyGame {
    fn save(&mut self, _: &mut Engine) -> kerosene::serde_json::Value {
        kerosene::serde_json::json!({ "inventory": self.inventory })
    }

    fn load(&mut self, engine: &mut Engine, data: &kerosene::serde_json::Value) {
        self.inventory = data["inventory"]
            .as_array()
            .into_iter()
            .flatten()
            .filter_map(|v| v.as_str().map(String::from))
            .collect();
    }
}
}
  • save is called for every saved game and every level change.
  • load is called after the map has loaded and after map_loaded, so it overrides whatever a fresh map set up. It is not called when save returned null.

The stock game saves its weapons this way: rounds, the weapon in hand and the dash cooldown.

From code:

CallWhen
engine.save_game(name)Saves now
engine.request_load(name)Loads at the start of the next frame. From a hook, use this.
engine.load_game(name)Loads now. Not from a hook.
engine.change_level(map, landmark)Changes level at the start of the next frame
engine.list_saves()Lists the saves
engine.read_save(name)Reads a save without loading it

A class whose state lives outside its fields registers on_restore. It runs instead of the spawn handler when a save puts the entity back. ambient_generic uses it to ask for its looping sound again:

#![allow(unused)]
fn main() {
ClassDef::new("ambient_generic").on_spawn(spawn).on_restore(restore)
}

The UI hears game_saved, game_loaded and level_changed as events, and the name of the last save is in save.last.

Cloud saves

When the store has a cloud, as Steam does, every save is also written there. On load, whichever copy is newer wins, the local file or the cloud’s, so a save follows the player to another machine. A local file that is missing is fetched from the cloud. Off Steam there is no cloud, and saves are only local.

Steam Auto-Cloud works as well: point it at the save/ directory.

Publishing

What has to be true before a game built on Kerosene goes to other people: what the licences require, what you may do, what you may not, and what you technically can do today but will pay for. The legal reasoning lives in Licensing and the technical gaps in Missing features; this page is the two of them condensed to the questions a release actually raises.

Note

None of this is legal advice. It is an accurate account of what the licences in this repository say and what the code does, so that you or an actual lawyer can move quickly.

Requirements

Kerosene is licensed GPL-3.0-or-later with the Kerosene Exception. The exception is what lets a closed-source game link the engine at all: it is an additional permission under GPL §7, granted on conditions, and shipping a game means meeting them. A distribution must carry:

  1. The licence texts. LICENSE (the GPL) and LICENSE-EXCEPTION (the Kerosene Exception), in full, beside the game.
  2. A notice that the program is built with Kerosene, what licence the engine is under, and that it comes with no warranty.
  3. A pointer to the corresponding engine source. The obligation travels with the copy, so it has to be in the copy: a URL, or an offer, in the README.txt.
  4. An attribution screen. Each time the game starts, before or together with its first interactive screen, it shows that it is built with Kerosene. Brief and dismissable is fine; disabled, hidden or illegible is not. A game with no graphical interface prints the line instead.
  5. Third-party notices for what travels inside the binary. Two things do: smartstring (MPL-2.0, pulled in by the script engine) and the typefaces egui embeds (SIL OFL 1.1 and Ubuntu Font Licence 1.0). Both are satisfied by preserving their notices.
  6. If you changed Kerosene’s own files: the whole modified engine stays under the GPL with the exception; it says “modified from Kerosene”, the version or commit it diverged from, and where Kerosene’s source is — in its notice, in the game’s README.txt and on the attribution screen; and its complete source is published where anyone can obtain it free of charge. See Forks and derived engines.

kiln --ship writes 1, 2 and 5 for you and leaves a labelled space for 3 and for the modified-engine line in 6. Item 4 is drawn by your game — the engine does not draw it for you yet (Missing features) — and the rest of 6 is yours alone; no tool can know whether the engine you built against is the one in the repository.

You can

WhatWhyWhat it costs you
Sell the gameThe licence does not restrict commercial useNothing
Keep your game code, levels, assets and scripts closedThe exception’s linking permission: an independent module does not become a covered work by being linkedThe permission’s conditions: engine source, the notice, the attribution screen
Statically link the engineRust does this by default and the exception names static linking explicitlyNothing more; there is no relinking clause
Ship on any storefrontNo storefront term conflicts with the licenceThe storefront’s own requirements, none of which the engine helps with yet (Platforms)
Ship the stock kerosene runtime, unmodified, as your gameA content-only project does exactly this; kiln --ship copies the runtime when .kproj names no game packageThe notice and licence texts, which kiln --ship writes
Fork the engineThe GPL permits itThe whole fork stays GPL with the exception, says “modified from Kerosene” and the version, and — when a closed game ships on it — publishes its whole source; and you now maintain a fork (Forks and derived engines)
Call your fork Kerosene: SomethingThe project wants derived engines to be able to say where they came fromThe name’s price, §2(d) of the exception: the whole engine published as source, a link to yours and a “modified from Kerosene” link to this one
Ship without worrying about Valve or id contentThere is none. Every format is Kerosene’s own, byte-tagged, and cannot open theirsNothing, provided you do not put any of theirs in
Let players mod the shipped gameLoose files under content/ shadow packed ones in the .vault, deliberately (crates/kerosene-vfs)It is also a tamper vector — see the table below

You cannot

WhatWhyWhere it is enforced
Omit the licence texts or the noticeThe exception requires them, and nothing breaks when they are missing, which is why it is the common mistakekiln --ship writes them unconditionally; deleting them afterwards is on you
Remove “built with Kerosene” and the source pointerIt is a condition of the linking permission and a §7(b) notice; without the permission the whole game is GPLAs above
Skip or hide the attribution screen§1(d) and §2(e) of the exception: it is the author attribution the GPL’s own §5(d) has interactive programs displayNothing enforces it; the Shipping checklist asks
Ship a modified engine closed, or without saying it is modified§1(a) and §1(c): the engine part stays GPL and a modified one is published whole; §2(b): it says “modified from Kerosene”. Decline the exception and the entire game is GPL—
Ship kerosene-tools — Chisel, the compilers, Kiln — inside your distribution without distributing its sourceThe tools are ordinary GPL binaries. A game that ships no compilers owes nobody anything for them; one that does inherits their obligationstools/kiln/src/ship.rs copies a named list of files, never a directory, and a test asserts no tool is ever in the result
Relicense Kerosene’s files under your own termsThe GPL does not permit it; only the copyright holder can—
Present the game as Valve’s, or use “Source”, “Hammer” or Valve marks as brandingThose names appear in these docs nominatively — to say what a thing is analogous to. Branding is a different use and is theirsNOTICE; the shipped README.txt disclaims affiliation
Ship a .vault older than the content in itYou would be shipping maps nobody builtcheck_archive in ship.rs refuses and lists the newer files
Ship from a checkout that has not built the archiveThere is nothing to copyship.rs refuses with “run kiln with no --only first”
Expect a warrantyThe licence disclaims one; the README.txt says so—
Ship an engine named after Kerosene with its source closed or its origin unstatedThe licence grants no right to the name; §2(d) of the exception grants it on conditionsForks and derived engines. Use another name and §2(d) does not apply

You can, but…

Everything here works, or can be made to work, and each has a cost the engine does not yet hide. These are the gaps a release runs into, in roughly the order you will hit them; the fuller list is Missing features.

WhatThe drawbackSource
Ship on Windows or macOSCI builds both (.github/workflows/ci.yml), so they compile. Nobody has run the result by hand. Budget the time to be the firstREADME, “Known limits”; missing-features.md §13
Ship a game with save/loadIt works — save, load, F5/F9, checkpoints, level changes, cloud mirroring — but there is no stock save/load menu yet, only the console and keys. A save is tied to the compiled map: rebuild a map after release and old saves of it may load with things out of placeSaving and level changes
Ship with a menu, HUD or options screenGame::ui draws egui over the world, so a HUD or menu is yours to write; there is no stock menu, and no stock attribution screen either, so the one the licence requires is a few lines in ui for nowmissing-features.md §8
Ship with an options screen for soundvolume is the only sound convar. There is no separate music or effects volume to setmissing-features.md §1
Ship with gamepad supportKeyboard and mouse only; no action-map layer, no rebinding UImissing-features.md §9
Ship multiplayerThe simulation runs headless, which is the hard part, but there is no wire protocol, prediction or replicationREADME; missing-features.md §12
Ship with crash reportingkerosene_console::install_crash_handler writes crash.log beside the binary with the panic, a backtrace and the last 64 log lines. Nothing sends it anywhere: the player has to find it and mail it to youcrates/kerosene-console/src/logging.rs; missing-features.md §13
Ship uncompressed textures.ktex has no block compression, so the download is several times the size it needs to bemissing-features.md §2
Ship a modified engineLegal: the whole fork public, “modified from Kerosene” everywhere the game names itself, plus §2(d) if you call it Kerosene. Practical: you maintain a fork, and every upstream fix is a merge. The project asks for a pull request firstForks and derived engines
Write gameplay in Rhai instead of RustThe script layer is sandboxed on purpose: it cannot allocate an entity, walk the BSP, open a file or touch the renderer. Level glue, yes; a weapon system, noscripting.md
Let players drop loose files beside the .vaultThe feature that makes modding trivial makes tampering trivial too. There is no signing, no manifest, no integrity check on loose files — only per-entry CRCs inside the archivetools.md, Vault; crates/kerosene-vfs
Install the game somewhere read-onlyThe first run writes engine.kcfg into the content tree beside the binary. In a read-only install directory that write fails and the defaults apply every runcrates/kerosene-config
Rely on the engine being deterministic (replays, ghosts)Asserted, not audited: nothing replays a session yet to prove itmissing-features.md §4
Ship on SteamBuild with the steam feature and ship with kiln --ship --steam. The drawbacks: your game links Valve’s proprietary SDK, which the Exception permits but the GPL alone would not, and there is no Steam Input yetSteam

Forks and derived engines

There are three ways to change the engine, and they are not equal.

A pull request. The cheapest by far, and the one the project asks for first. Send the change upstream, build on the updated engine, and the fix exists once — reviewed, tested, and maintained by someone other than you. Every other option below means you carry it.

A private fork. You changed something and you ship it inside your own game. A modified Kerosene is a modified Kerosene wherever the changed files live, so what you owe is what the exception says of one:

  • the whole modified engine stays under the GPL with the exception (§1(a));
  • if the game is closed — that is, if you are using the linking permission — the complete source of the modified engine is published where anyone can obtain it free of charge, not only offered to your players (§1(c));
  • it says “modified from Kerosene” (or “built from Kerosene”), the version or commit it diverged from, and where Kerosene’s source is — in the engine’s own notice, in the game’s README.txt, and on the game’s attribution screen (§2(b), §2(e));
  • it is not presented as the original or as endorsed by this project.

Decline the exception and you are under the plain GPL: the entire game is then a covered work, and only then is publication not required — recipients get the source instead.

A derived engine. The Source lineage has a precedent for this: Respawn took Source, rewrote large parts of it, and shipped Titanfall on the result — a branch that was recognisably Source and recognisably not. Kerosene would like to be forked that way. If you rewrite the renderer, or the physics, or half of everything, and other people are going to build games on what you made, that is a derived engine, and you are welcome to name it after this one so its lineage is plain: Kerosene: Ultimate, Kerosene NG, whatever fits. Everything a private fork owes, a derived engine owes too; the attribution-screen requirement is inherited, so every game built on the fork shows “built with Kerosene NG, modified from Kerosene”.

The naming policy

The licence grants no right to the name. §2(d) of the exception grants it, on conditions: call your engine something a reasonable person would take for a Kerosene branch — a name that contains “Kerosene” or is plainly derived from it — and you accept these:

  1. Publish the whole modified engine as source, under the GPL with the exception — the engine, not only the files you changed — where anyone can obtain it free of charge. A Kerosene-named engine is open the way Kerosene is open, whether or not a closed game ever ships on it.
  2. Attribute Kerosene as §2(b) asks: “modified from Kerosene”, the version or commit you diverged from, and a link to Kerosene’s source, in your engine’s notice and in the notice and attribution screen of every game built on it.
  3. Say it is a derivative. Do not present it as the official Kerosene or as endorsed by this project; a line in the README saying it is an independent fork is enough.

Use a name that is not Kerosene’s and only §2(b) and the linking conditions apply. The project would rather see a well-named derivative than a closed one with a different label.

What stays the same whichever way you go: the two licence texts ship, the notices ship, kiln --ship still writes them, and no Valve or id mark becomes part of anybody’s branding.

How kiln --ship helps

kerosene-tools kiln --ship dist

builds the content if it is stale, builds the game package if the project names one, and assembles:

dist/
  my_game            the game, or the engine runtime when the project names none
  my_game.kproj   content = "content", so the game finds its own archive
  content/
    my_game.vault
  LICENSE            the GNU General Public License, version 3, full text
  LICENSE-EXCEPTION  the Kerosene Exception, full text
  README.txt         what this is, and the notices the licence asks for

Three properties are worth knowing:

  • It copies a named list, not a directory. Nothing from target/ sweeps in by accident, and in particular no tool does.
  • It refuses rather than warns. A missing archive, or one older than any file in the content tree, stops the ship stage. Run kiln again first.
  • The licence texts are compiled into kiln, so shipping works from an installed toolset that is nowhere near a checkout.
  • --steam adds Valve’s library and nothing else. It builds with the steam feature, puts libsteam_api beside the game with an rpath to find it, and writes SteamPipe scripts next to dist/ rather than inside it. See Steam. On Windows, every ship links the C runtime statically, so players need no Visual C++ redistributable.

What it leaves for you: the README.txt has an Engine source section that says the source must be available and asks you to say where — and, if your engine is modified, which version it diverged from and where the whole modified source is published. Fill it in. That, the attribution screen the game draws itself, and testing the result on a machine that is not yours — see the Shipping checklist.

Platforms & distribution

Where a Kerosene game can run today, where it can be sold, and what each choice costs. The short version: Linux is the only platform anyone has tested; everything else is untested rather than known broken, and no storefront has any integration yet.

Operating systems

PlatformStatusNotes
LinuxDeveloped and tested hereAudio needs ALSA headers to build (libasound2-dev / alsa-lib-devel; on an image-based system such as Bazzite or Silverblue, rpm-ostree install alsa-lib-devel); without them build with --no-default-features and everything but sound works. kerosene-tools doctor checks. Players need nothing extra
WindowsCompiled in CI; never played by handwgpu, winit and cpal all support it. CI compiles the workspace and its tests there. kiln --ship links the C runtime statically, so players need no Visual C++ redistributable
macOSCompiled in CI; never played by handSame dependencies, same caveat. wgpu uses Metal
ConsolesNoNo SDKs, no plans in the tree
MobileNoTouch input does not exist
WebNowgpu can target WebGPU; the file system, audio and process model here assume a desktop

CI (.github/workflows/ci.yml) runs the tests on Linux, and makes a game with kerosene-tools new there and plays it headless. On Windows and macOS it only compiles, so “compiles” is known there and nothing more: if you ship on either you are the first to find out whether it runs.

Build each platform’s copy on that platform: clone the game there and run cargo ship. kiln --ship dist --target <triple> cross-compiles for a developer who has that target’s linker and libraries set up; that setup is Rust’s to document, and building natively is the supported way. scripts/build-content.sh is a Linux shell script, but it is only a wrapper: kerosene-tools kiln is what it calls and that is a program, on every platform.

Storefronts

Nothing in the licence stops you selling on any of these. Steam is the one the engine integrates with; see Steam.

StoreWhat worksWhat is missing
itch.ioZip the kiln --ship folder and upload itA butler push target in Kiln
SteamThe steam feature: achievements, stats, leaderboards, rich presence, cloud files, DLC, the overlay and Workshop mounting. kiln --ship --steam installs Valve’s library and writes the SteamPipe scriptsSteam Input, lobbies and networking, leaderboard downloads
GOG, EpicThe folder, as aboveEverything the store’s SDK offers

The Workshop is worth a sentence because it is the store feature the engine is built for. The formats are open and each map is one .vault, so a subscribed map mounts as one more VFS layer with no format work at all. The engine does exactly that at startup, and kerosene-tools workshop upload puts a .vault up.

Dedicated servers

kerosene --headless <ticks> runs the simulation with no window, and it is what a dedicated server is, not a test mode — but there is no network stack for it to serve. Until there is, a server is a single-player game with no screen.

Packaging

kiln --ship produces a folder, and a folder is the distribution: no installer, no auto-updater, no code signing. Zip it. On Linux the executable bit is preserved through the copy; on other platforms the archive format has to preserve it for you.

The game finds its content by climbing from the executable, and the shipped .kproj says content = "content", so the folder works from wherever it is unpacked and does not care about the working directory.

What the player needs

  • A GPU and driver wgpu can talk to: Vulkan on Linux, and presumably DX12 or Metal elsewhere.
  • Write access to the content folder on first run, for engine.kcfg (see Publishing).
  • Nothing else. There is no runtime, no redistributable, no shared library: the game is one static binary and one archive.

Steam

Kerosene talks to Steam through Valve’s Steamworks SDK. It covers:

  • achievements, stats and leaderboards
  • rich presence
  • cloud files
  • DLC checks
  • the overlay
  • the Workshop

The same map, script and game code works off Steam too. Without Steam, the store is a stand-in that keeps achievements and stats for the session and answers every request the way Steam would. Nothing needs an if steam branch.

Turning it on

Steam is an opt-in Cargo feature. A default build of Kerosene links nothing that is not free software, and the Steamworks SDK is not. So a Steam build is one you ask for:

# your game's Cargo.toml
[dependencies]
kerosene = { ..., features = ["steam"] }        # always Steam

# or, better, as a feature of your own that kiln can turn on:
[features]
steam = ["kerosene/steam"]

The stock runtime has the same switch:

cargo run -p kerosene-runtime --features steam

The SDK comes from the steamworks crate, whose Rust bindings are MIT/Apache and which carries Valve’s headers and redistributable libraries. You need no C++ toolchain, no bindgen and no separate SDK download.

The project file

Everything Steam needs to know goes in the .kproj:

project
{
    "name"        "My Game"
    "startmap"    "mg_intro"

    "steam_appid" "480"          // your app id; 480 is Valve's test app, Spacewar
    "steam_depot" "481"          // optional; the app id plus one by default

    "achievements"
    {
        "ACH_FIRST_DOOR"  "Open the first door"
        "ACH_TEN_DOORS"   "Open ten doors"
    }
    "stats"
    {
        "doors_opened"    "int"
        "distance"        "float"
    }
    "dlc"
    {
        "1234560"         "Soundtrack"
    }
}

Achievements and stats are declared. An id that is not listed here is refused, and the console says which id and why. Without that rule, a typo in a map would be an achievement nobody can ever get. Each id must also be set up under the same API name in the Steamworks partner site.

Running it

cargo run --features steam with the Steam client openConnects as the signed-in user. platform_status in the console says so
The same, with Steam closedWarns once and falls back to the local store. The game runs
--no-steamSkips Steam even in a Steam build
--headlessNever tries Steam: a server has no signed-in player
A release build started from its folder rather than from SteamRelaunches itself through Steam, as Valve requires, unless steam_appid.txt sits beside it

Shift+Tab opens the overlay, and the game pauses under it by opening the pause menu. The menu stays open after the overlay closes, so the player returns to a menu rather than straight into play.

From a map: entity I/O

ClassKeysInputsOutputs
logic_achievementachievement, progressmaxUnlock, Clear, SetProgress <n> [max]OnUnlocked
logic_statstat, threshold, achievementSet <n>, Add [n], Increment, StoreOnChanged (the value), OnThreshold
logic_leaderboardleaderboard, sort (desc for points, asc for times)Submit <score>OnSubmitted, OnRankImproved (the rank), OnFailed
logic_richpresencestatusSetStatus <text>, SetKey <key> <value>, Clear
logic_platformdlcRefresh, OpenOverlay [dialog], OpenUrl <url>, OpenStore [appid], CheckDlc [appid]OnAvailable / OnUnavailable at map start, OnOverlayOpened, OnOverlayClosed, OnDlcOwned, OnDlcNotOwned

Results fire on every entity of the class that names the same thing, not only on the one that asked. For example, an achievement a script awards still fires the logic_achievement for it. A leaderboard’s answer arrives a moment later from Valve’s servers and lands on the entity for that board.

A logic_stat with a threshold and an achievement does the usual thing for “open ten doors”. It shows Steam’s progress toast on the way (3/10, 4/10, …) and awards the achievement when the stat reaches the threshold.

Kerosene Demo has one example: typing the code into the keypad in kero_start fires ach_keypad’s Unlock, and the HUD toasts “Code breaker”. Its kerosene-demo.kproj declares ACH_KEYPAD.

From a script: platform (or steam)

Map scripts and UI scripts have the same object:

platform.unlock("ACH_FIRST_DOOR");
steam.add_stat("doors_opened", 1);          // `steam` is the same object
platform.progress("ACH_TEN_DOORS", 3, 10);
platform.submit_score("atrium_time", 5230, true);   // true: lower is better
platform.presence("status", "In the atrium");
if platform.owns_dlc(1234560) { ... }
platform.open_overlay("achievements");

print(platform.user);                       // the Steam name, or the OS user
if platform.available { ... }               // a store is connected
if platform.is_unlocked("ACH_FIRST_DOOR") { ... }
let doors = platform.stat("doors_opened");

The object works inside functions as well as at the top of a file. What comes back arrives as an event:

  • Map scripts: fn on_platform_event(name, data).
  • UI layouts: on:achievement_unlocked="..." or on_event.
EventData
achievement_unlocked, achievement_clearedthe id
achievement_progressid current max
stat_changedname value
score_submittedboard score rank improved
score_failedthe board
dlc_checkedappid owned
overlay_opened, overlay_closed
workshop_mountedthe item id

UI bindings read the same state from the store:

  • platform.available, platform.name, platform.user and platform.overlay
  • platform.achievements.<id>, platform.stats.<name> and platform.dlc.<appid>
  • platform.names.<id>, the display names from the project

The stock HUD’s achievement toast is built from these.

From game code

#![allow(unused)]
fn main() {
use kerosene::platform::PlatformAction;

if let Err(why) = engine.platform_apply(&PlatformAction::AddStat { name: "kills".into(), delta: 1.0 }) {
    log::warn!("{why}"); // e.g. the stat is not declared in the .kproj
}
let user = engine.platform().user();
}

Game::platform_event hears every event after the entities and the map script. engine.platform_mut().cloud_write(name, bytes) and cloud_read use Steam Cloud’s file API.

Cloud saves

There are two ways to do it:

  • Steam Auto-Cloud. Configure it in Steamworks to sync a folder, and no code is needed at all.
  • The cloud API. cloud_write / cloud_read store named files in the game’s cloud quota.

Off Steam, the stand-in keeps “cloud” files in memory. Saved games use these calls on their own: every save is mirrored to the cloud, and the newer copy wins on load. See Saving and level changes.

The Workshop

A subscribed Workshop item is a folder. At startup, the engine mounts every .vault in every subscribed, installed item as one more layer of its file system. A map from the Workshop is found like any other map, and nothing about the format changes.

To upload one:

cargo build -p kerosene-tools --features steam
kerosene-tools workshop upload content/my_map.vault --title "My Map" --preview shot.png
kerosene-tools workshop upload content/my_map.vault --item 3141592653 --note "Fixed the leak"

The first form makes a new item and prints its id. Pass that id next time to update the item rather than make another. You need the Steam client running and signed in to an account that owns the game.

Shipping: kiln --ship --steam

kerosene-tools kiln --ship dist --steam                 # a build to upload
kerosene-tools kiln --ship dist --steam --steam-dev     # plus steam_appid.txt, to test outside Steam
kerosene-tools kiln --ship dist --steam-upload myacct   # build, then upload with steamcmd

On top of an ordinary ship, --steam does five things:

  1. Builds with the steam feature, into target/ship so the everyday build is left alone. A project with no game package gets the stock runtime, built from the engine checkout.
  2. Installs Valve’s redistributable (libsteam_api.so, steam_api64.dll or libsteam_api.dylib) beside the game. The copy comes from the build that linked it, so the versions always match.
  3. Makes it findable. On Linux the binary gets an rpath of $ORIGIN, and on macOS of @executable_path. Windows looks beside the executable on its own. The shipped game starts from any working directory.
  4. Writes the SteamPipe scripts, steam_build/app_build_<appid>.vdf and depot_build_<depot>.vdf, beside dist/ rather than in it. It then prints the steamcmd line that uploads them. The depot script excludes steam_appid.txt and *.pdb, so a test file can never reach players.
  5. Says so in README.txt: the library is Valve’s, redistributed under the Steamworks SDK Access Agreement, and not part of Kerosene.

Kiln never handles credentials. With --steam-upload, steamcmd asks for the password and Steam Guard code itself.

On Windows, every ship links the C runtime statically, Steam or not. So a player needs no Visual C++ redistributable either, and the only redistributable a Kerosene game carries is Valve’s.

Licensing

The Kerosene Exception lets a game combine the engine with Independent Modules, and the Steamworks SDK is one. A Steam build of your game is a Combined Work like any other. The engine part stays GPL with its source available, and the SDK stays Valve’s under Valve’s terms.

The Kerosene source tree carries none of the SDK. It arrives through Cargo only when a game turns the feature on.

What is not here yet

  • Steam Input, and gamepads in general. Under Steam, Steam Input’s virtual pads appear as ordinary controllers, so they come with gamepad support.
  • Lobbies, matchmaking and networking.
  • Inventory and microtransactions.
  • Downloading or reading leaderboard entries. Posting works; showing a table does not yet.
  • Workshop browsing from inside the game. The Steam client’s Workshop page does the subscribing.

Shipping checklist

In the order you will do them. Each item links to the page that explains it.

Before the build

  • startmap in .kproj names the map New Game starts on.
  • The main menu, splash and loading screen are the game’s own, or the base ones are what you want: ship ui/menus/main.kui, splash.kui, loading.kui to replace them.
  • game in .kproj names your Cargo package — or is deliberately absent, because you are shipping a content-only project on the stock runtime. (Making a game)
  • kerosene-tools kiln --dry-run lists every stage you expect and no map you have forgotten.
  • kerosene-tools kiln runs clean: no leaks reported at the end, no missing texture or model. A leaking map compiles anyway, so read the summary.
  • You have played every map from the compiled .vault, not from loose files — loose files shadow the archive, and a file you forgot to add will look fine on your machine and be missing on the player’s.

The build

  • kerosene-tools kiln --ship dist succeeds. If it refuses, it says why: a stale archive means run kiln again first. (Publishing)
  • dist/ holds the game binary, <name>.kproj, content/<name>.vault, LICENSE, LICENSE-EXCEPTION and README.txt — and no kerosene-tools binary.
  • README.txt, “Engine source”: replaced the instruction with the URL or offer where the Kerosene source you built against can be obtained. Nothing does this for you.
  • The game shows an attribution screen when it starts — “Built with Kerosene”, before or with the first interactive screen — that a player cannot switch off. The engine does not draw one for you yet; a few lines in Game::ui do it. (Publishing)
  • If you changed any Kerosene file: the whole modified engine is published where anyone can get it, and the README.txt and the attribution screen say “modified from Kerosene” with the version or commit you diverged from and where the modified source is. Better: the change is a pull request upstream. (Publishing)
  • If the engine you built on carries a Kerosene-derived name: the same, plus its source is published whether or not your game is closed. (Publishing)
  • The engine commit you built against is tagged or written down, so the source pointer means something in a year.

If it ships on Steam

  • steam_appid in .kproj is your app id, not 480.
  • Every achievement and stat the game awards is declared in the .kproj and set up under the same API name in Steamworks. achievement_list in the console shows what the game declares. (Steam)
  • kerosene-tools kiln --ship dist --steam succeeds, and dist/ holds Valve’s libsteam_api.so / steam_api64.dll / libsteam_api.dylib beside the game.
  • No steam_appid.txt is in what you upload. --steam-dev writes one for testing, and the depot script excludes it anyway.
  • Started from Steam, platform_status says steam (connected), and Shift+Tab pauses the game.

Before it goes out

  • Run dist/<name> from a different working directory. It must find its own content.
  • Run it on a machine that is not yours, ideally one that has never had a Rust toolchain or a Kerosene checkout on it.
  • Run it once from a read-only location and confirm it still starts — engine.kcfg cannot be written there and the defaults must do.
  • If the target is Windows or macOS: CI builds it; you are still the first to run it. Test there before announcing it. (Platforms)
  • Your store page says the things the engine cannot do yet and your game therefore does not: no gamepad, unless you built it. (Publishing)

FAQ

Short answers. Each links to the page with the long one.

Can I sell a game made with Kerosene? Yes. The licence does not restrict commercial use. (Publishing)

Do I have to open-source my game? No. The Kerosene Exception lets you link the engine into a closed game. Your code, levels, assets and scripts are yours; the engine part stays GPL with its source available. (Licensing)

Isn’t the GPL viral? How can my game be closed? On its own it would be. The exception is an additional permission under GPL §7 that says a game linking Kerosene is not a covered work, on conditions: engine source, the built-with notice, an attribution screen, and — if you changed the engine — the whole modified engine published. (Licensing)

What do I have to ship alongside the game? Both texts (LICENSE, LICENSE-EXCEPTION) and a README.txt with the notices. kiln --ship writes all of it; you fill in where the engine source can be obtained. And the game itself shows an attribution screen at start. (Shipping checklist)

What is the attribution screen? “Built with Kerosene”, shown each time the game starts, before or with the first interactive screen. It can be brief and dismissable; it cannot be switched off. Draw it in Game::ui for now — the engine will draw it for you eventually. A game on a modified engine shows “built with Fork, modified from Kerosene”. (Publishing)

Can I remove the “Built with Kerosene” notice? No. It is a condition of the linking permission and a notice §7(b) of the GPL lets the project require. Remove it and the permission goes with it, which makes the whole game GPL. (Publishing)

Can I ship Chisel or the compilers with my game, for modders? Not inside the distribution kiln --ship makes, and not without also distributing their source: the tools are GPL binaries in their own right. Point modders at the Kerosene repository instead, or build your own mygame-tools on kerosene::tools, which ships like the game. (Publishing)

I changed the engine. What do I owe? The whole modified engine stays GPL with the exception; it says “modified from Kerosene” with the version you diverged from and a link back — in its notice, the game’s README.txt and the attribution screen; and if your game is closed, the modified engine’s complete source is published where anyone can get it. The project would rather have a pull request. (Publishing)

Can I make my own engine out of Kerosene and call it “Kerosene: Ultimate”? Yes, and the project would like that — the way Titanfall’s engine was a rewritten branch of Source. The name costs what a modified engine already owes plus publishing its whole source whether or not a closed game ships on it, and saying plainly it is a derivative rather than the official one. (Publishing)

Do I owe anything for Symphonia (the MP3/FLAC decoder)? No. Only Timbre, a build tool, links it, and kiln --ship never puts a tool in a distribution. The one MPL-2.0 dependency a game does carry is smartstring, and its notice is written for you. (Licensing)

Can I use Source or Hammer assets, or open a .vmf? No, on both counts. Kerosene’s formats are its own and deliberately cannot read theirs, and the project ships no Valve or id content. Bring your own. (Licensing)

Is it game-ready? Not yet. It is 1.0.0 alpha. Everything works end to end — make a game with kerosene-tools new, draw a level, compile it, walk around it, save and load — but main menus, gamepads, combat, networking and crash reporting do not exist yet. (Publishing)

How do I start? cargo install kerosene --features tools, kerosene-tools new mygame, cargo play. (Getting started)

Will an update break my game? Not without saying so. Kerosene’s version follows Semantic Versioning on the kerosene crate’s API: cargo update takes fixes and additions, and a breaking change waits for a major version, with the changelog saying what to change. The 1.0.0 alphas are the exception. (Versioning)

Does it run on Windows or macOS? CI runs every test on both, and makes and plays a new game on each. Nobody has played one there by hand yet. (Platforms)

Can players mod a shipped game? Yes, trivially: a loose file under content/ shadows the same path in the .vault. That is also why there is no tamper protection. (Publishing)

Can I make a game without writing Rust? Yes — a content-only project: maps, entity I/O and Rhai scripts on the stock runtime. The moment you need something the stock entity classes cannot do, you need a game crate. (Making a game)

Architecture

How the pieces fit, and why they are arranged this way.

The shape of the thing

---
config:
  layout: elk
---
flowchart LR
    n1["Source content"] --> n2(["Build-time tools"])
    n2 --> n3["Runtime"]
    n4(["art/.png"]) --> n7@{ label: "<span style=\"color:\">alchemy</span>" }
    n7 --> n8(["materials/*.ktex &amp; *.kmat_c"])
    n8 --> n9["Kerosene"]
    n5(["art/.obj"]) --> n10["forge"]
    n10 --> n11(["models/*.kmdl"])
    n11 --> n9
    n6(["maps/.kmap"]) --> n12["cleave"]
    n12 --> n13(["maps/*.kbsp"]) & n15(["`*.kprt`"])
    n13 --> n14["umbra"]
    n15 --> n14
    n14 --> n16["+vis"] & n17["radiance"] & n20["resonance"]
    n16 --> n9
    n17 --> n18["+light"]
    n20 --> n21["+sound"]
    n21 --> n16
    n18 <--> n16 & n19["chisel"]
    n19 --> n6

    n1@{ shape: rect}
    n3@{ shape: rect}
    n7@{ shape: rounded}
    n9@{ shape: rect}
    n10@{ shape: rounded}
    n12@{ shape: rounded}
    n14@{ shape: rounded}
    n16@{ shape: rect}
    n17@{ shape: rounded}
    n18@{ shape: rect}
    n19@{ shape: rounded}
    style n1 stroke:#2962FF,color:#2962FF
    style n2 stroke:#FF6D00,color:#FF6D00
    style n3 stroke:#AA00FF,color:#AA00FF
    style n4 stroke:#2962FF,color:#2962FF
    style n7 stroke:#FF6D00
    style n8 color:#D50000,stroke:#D50000
    style n9 color:#AA00FF,stroke:#AA00FF
    style n5 stroke:#2962FF,color:#2962FF
    style n10 stroke:#FF6D00
    style n11 color:#D50000,stroke:#D50000
    style n6 stroke:#2962FF,color:#2962FF
    style n12 stroke:#FF6D00
    style n13 color:#D50000,stroke:#D50000
    style n15 stroke:#D50000,color:#D50000
    style n14 stroke:#FF6D00
    style n17 stroke:#FF6D00
    style n19 stroke:#FF6D00

Everything above the line happens once, on a developer’s machine or a build server. Everything below happens sixty-four times a second on a player’s.

That division is the single most important thing about a BSP engine, and it is why the tools are not part of the engine. Visibility takes minutes to compute and microseconds to query. Lighting takes minutes to bake and nothing at all to sample. Neither belongs in the engine.

Crate dependencies

---
config:
  layout: elk
  theme: default
  look: neo
  fontFamily: '''Source Code Pro Variable'', monospace'
---
flowchart TB
    n1["Kerosene-math"] --> n2["Kerosene-kv"] & n4(["cleave"]) & n8["Kerosene-physics"] & n9["Kerosene rigid"] & n11["kerosene-entity"] & n13["Kerosene-vfs"] & n14["kerosene-asset"] & n15["kerosene-render"] & n17["Kerosene (runtime)"]
    n2 --> n3["kerosene-map"] & n5["kerosene-bsp"]
    n3 --> n5 & n4
    n4 --> n5
    n6(["umbra"]) --> n5
    n7(["radiance"]) --> n6
    n20(["resonance"]) --> n6 & n14
    n5 --> n8
    n10(["Box3d-rust"]) --> n9
    n12["kerosene-game"] --> n11
    n14 --> n13 & n15
    n15 --> n16["kerosene-engine"]
    n16 --> n18["kerosene"]
    n12 --> n18
    n18 --> n17

    n1@{ shape: rounded}
    n2@{ shape: rounded}
    n8@{ shape: rounded}
    n9@{ shape: rounded}
    n11@{ shape: rounded}
    n13@{ shape: rounded}
    n14@{ shape: rounded}
    n15@{ shape: rounded}
    n17@{ shape: rounded}
    n3@{ shape: rounded}
    n5@{ shape: rounded}
    n12@{ shape: rounded}
    n16@{ shape: rounded}

Nothing points upward. kerosene-math knows about nothing; kerosene-engine knows about everything except the game. The tools sit off to the side, depending on the format crates but never on the engine.

kerosene is the top of it all, and the product: the game crate. It re-exports the engine’s stable crates as modules and the rest under kerosene::internals, wraps the stock game as kerosene::game::Stock, and provides launch. It is the one crate a game names, its API is what Kerosene’s version number follows (Versioning), and the runtime binary is a call into it.

Underneath everything is the engine’s base content, crates/kerosene-engine/base/base.vault: the developer textures, the stock props, sounds and UI, and a plain room, kerosene_room, compiled into the engine and mounted as the last layer of the file system. A game with no content of its own still has something to show, and a game’s own file at the same path always wins.

The game seam

The engine has no game of its own. kerosene-engine defines a Game trait – classes, schema, setup, map_loaded, player_spawned, pre_tick, tick, frame, entity_request, console_request, wants_ui, ui, ui_event, save, load, can_save, player_damaged, player_died, platform_event, map_unloading and shutdown, every one with a default – and runs whatever implements it, with &mut Engine in every hook. kerosene-game is the stock implementation’s classes and knows nothing of the engine; the engine tests against it as a dev-dependency and kerosene::game::Stock is the type that joins the two.

What used to be game code the engine called by name – what a trigger does to the player, the everywhere flag on a sound – is now an engine convention (kerosene_engine::triggers, audio::SF_EVERYWHERE) that any game’s classes get by following the field names. The game is held outside the engine while a hook runs, which is what lets a hook take &mut Engine; the cost is that a hook cannot cause another hook, so a game changes maps with request_map.

kerosene-config is not on the diagram because it sits to the side of all of it: a small crate on top of kerosene-kv that reads engine.kcfg, the settings every program shares (which renderer, how big the window). The game, Chisel, and the tool windows all ask it the same question, so the renderer is chosen once in a file rather than once per program.

kerosene-rigid is the newcomer beside kerosene-physics. Player movement stays in kerosene-physics (it is Source’s gamemovement, and that is the feel); kerosene-rigid wraps box3d-rust, Erin Catto’s 3D engine ported to pure Rust, for everything the player is not – physics props, the rigid bodies that tumble and roll. It speaks Kerosene units natively: Box3D’s length-unit scale is set to inches once at startup, and no coordinate conversion happens anywhere.

In the engine, a PhysicsProps owns one PhysicsWorld: at map load every static world brush (and every detail) becomes a convex hull, moving brush entities become static bodies that follow their entity’s pose, and each prop_physics entity becomes a dynamic box (shaped from its model’s bounds, loaded through kerosene-asset). Once a tick the simulation is stepped and its poses written back to the entities. The renderer draws those props as .kmdl models at their simulated pose, and phys_debug turns the collision boxes into a wireframe overlay. The player’s movement traces against the prop boxes too (through PlayerCollision), and the use key doubles as a pick-up tool: grab a prop, carry it, press use again to launch it. prop_dynamic_spawner is the game-side spawner that creates the props in the first place.

Two edges in that picture are there for a reason worth stating. chisel depends on alchemy, because the editor builds the content tree’s textures itself rather than re-invoking the toolset for it. And everything – the editor, the compilers, the runtime – finds the content tree through kerosene_vfs::root, one function they all call, and finds the runtime and the toolset’s subcommands through kerosene_vfs::toolchain, likewise. Each of them working either out separately is not a hypothetical: they did, they disagreed, and a tool looking in the wrong directory is indistinguishable from a tool that is broken. A shared answer that explains itself is the whole of the fix.

kiln sits above the compilers rather than beside them: it is the one tool that runs the others, and it does so by re-invoking the same executable with the compiler’s name as a subcommand, so nothing about the pipeline being one program leaks into the compilers being separate stages.

The map pipeline in detail

Cleave: .kmap → .kbsp

  1. Brushes. Each .kmap solid becomes a set of interned half-space planes. Plane interning matters more than it sounds: two faces meant to be coplanar must end up sharing one plane index, or the tree splits along a hair’s width between them and the compile explodes.

  2. CSG. Every face is cut against every brush that could bury it, and the buried parts are dropped. On a real map this removes about a third of all faces before anything else runs. The subtle case is coplanar faces: two brushes flush side by side keep exactly one shared face (lower index wins, deterministically); two brushes back to back drop it entirely.

  3. The tree. Built top-down from the brushes’ own planes, choosing at each step the plane that scores best on Quake’s heuristic — coplanar faces are worth a great deal, splits cost the same amount, axial planes get a bonus. Detail brushes are held out of this entirely, which is the single biggest lever a designer has over compile time.

  4. Portals and flood fill. Portals are the polygons where two leaves touch. Flooding outward from every entity through passable portals answers three questions at once: is the map sealed, which leaves are inside it, and what is the connectivity graph the visibility compile needs.

  5. Outside removal. Leaves the flood never reached become solid. This deletes the entire outer shell of the level — the backs of every wall, the underside of the floor — and it is why a sealed map is so much cheaper than an open one.

  6. Emission. Faces are filed down the tree into the leaves that can see them, vertices are welded so adjacent faces share records (or their seams crack open under rounding), and edges are shared through the surfedge indirection.

Umbra: visibility

For every cluster, which other clusters can be seen from anywhere inside it.

Base vis is cheap and generous — two portals might see each other if each pokes out on the far side of the other, flooded transitively. The full portal flow then narrows a shrinking sight cone with separating planes: the planes touching one edge of the source and one vertex of each portal passed through. When the cone closes to nothing, everything beyond is invisible.

The result is a bit per cluster pair, run-length encoded. At runtime the renderer decompresses one row and skips every leaf not in it.

Resonance: acoustics

Every leaf is listened to from inside: a few hundred rays go out and bounce until they have nothing left, and each surface they strike gives up how much it soaks up per band, from its material. The averages — how far a ray goes between surfaces, how much it loses at each — are what Eyring’s formula for reverberation time wants, measured in the room’s actual shape rather than assumed for a box.

Leaves that touch and sound alike are then gathered into rooms by union-find over the portal graph, widest portals first, merging only while the merged average still sounds like both halves. A hall the tree cut into twenty leaves is one hall again; a doorway is a boundary because the two sides disagree. One record per room goes in the map, and a leaf-to-room table beside it.

Radiance: lighting

Every lit face carries a grid of luxels. For each, find where it sits in the world and ask every light whether it can see it, then bounce.

Two details do most of the work of making it look right. Grid points near the edge of an angled face fall inside neighbouring geometry — left alone they bake black and produce a dark rim around every surface, so they are pulled toward the face centre until they clear. And shadow rays start slightly off the surface, because starting exactly on it means the first thing every ray hits is the face it came from.

Runtime

The tick

Fixed at 64 Hz, decoupled from rendering. A 240 Hz display draws 240 smooth frames of a 64 Hz simulation, not 240 simulations. The catch-up accumulator is capped so that a stall — a breakpoint, a window drag — does not produce a burst of hundreds of ticks that looks like the world fast-forwarding.

Each tick:

  1. Categorise the player’s position: are they standing on something?
  2. Friction, then acceleration, then the move itself. That order matters: running friction after acceleration makes ground movement mushy and breaks air strafing entirely.
  3. Update triggers from the player’s box.
  4. Run the entity queue: deliver due events, run thinks, reclaim removals.

Collision

Traces work against brushes, not triangles. A brush is a handful of planes, so testing one is a handful of dot products regardless of how detailed its surface is, and the BSP narrows the search to the brushes actually along the path.

Brush entities — doors, platforms — are separate models kept out of the world tree so they can move without re-splitting it. They are traced separately and the nearest hit taken. Forgetting that is how you walk through every door in the game.

Rendering

What to draw is decided long before the GPU is involved:

  1. Find the viewer’s cluster.
  2. Take its PVS row — the clusters that can possibly be seen.
  3. Frustum-cull the leaves in those clusters, then their surfaces.
  4. Draw what is left, batched by material.

The PVS and the frustum do different jobs and neither subsumes the other: the PVS removes rooms you cannot see through any opening, the frustum removes what is behind you.

Brush entities are a second pass, and have to be. A door, a lift, anything tied to a class is compiled as its own model with its own leaves, and those leaves are not in the world’s PVS. A leaf walk therefore finds the world and nothing else — which is exactly what happened: every brush entity in every map was built into the mesh and never drawn, and the sample map’s door was simply not there. They are drawn after the world, one model at a time, culled against the frustum by their bounds rather than by the PVS. There are a handful of them, they are the things the player walks up to, and culling one wrongly costs far more than drawing it.

Each carries a displacement — where its entity has moved to — applied in the vertex shader through a uniform with a dynamic offset. That displacement comes from the same fields the collision code traces against, so what you see and what you walk into are the same thing by construction.

Lightmaps pack into one atlas per section, so a section draws in as many calls as it has materials rather than one per face.

Streamed sections

A map can be more than one machine wants resident at once. Chisel lets the designer mark a visgroup as streamed; Cleave numbers those visgroups as sections after the always-loaded world (section 0) and tags every world face and brush with its section. At runtime kerosene_engine::streaming decides, once a tick, which sections should be resident:

  • the world, always;
  • any section with a face in a cluster the player’s cluster can hear — the PAS, which is the PVS flooded one doorway further. A room therefore starts loading a doorway before it can be seen, and that doorway is the margin that hides the build;
  • any section an awake physics prop is inside, so a crate thrown into the next room does not fall through a floor that has since gone.

A section that stops being wanted lingers sv_stream_linger seconds before it is dropped, so pacing over a threshold does not thrash. With no vis data, with the player outside the world, or with sv_stream 0, everything is wanted.

What streams is the expensive part and only that: each section’s render mesh and lightmap atlas, built on a worker thread from the shared BSP and uploaded on the main thread, and its rigid-body hulls. The BSP tree, the entity list, the player’s traces and entity I/O are whole whatever is loaded — the tree is small, the traces have to work everywhere, and a logic_relay in an unloaded room still fires. Brush entities are never streamed: a door is drawn and collided with wherever it has moved to. This is sections of one map, not an open world: everything is still one .kbsp, compiled and lit as one.

r_stream_debug 1 draws each section’s bounds in the colour of its state. content/maps/two_rooms.kmap in Kerosene Demo is a small map with two streamed rooms.

Entities

Entities are a bag of named fields rather than typed structs, because the meaningful fields belong to the game, not the engine. Classes are registered handlers — the same split Source draws between its engine and its game DLL. kerosene-entity knows how to route an input; the game – kerosene-game for the stock classes, yours through the Game trait – decides what Open means.

Outputs become queued events even at zero delay, so an entity firing at itself cannot recurse into the stack, and ordering does not change when a delay is added. Handles carry a generation, so an event naming an entity that died before it fired resolves to nothing rather than to whoever took its slot.

Testing

Where a subsystem can be tested without a GPU or a window, it is:

  • Movement runs against a hand-built box world — a floor, a step, a wall — rather than a compiled map, so a failing test is a movement bug rather than a map bug.
  • Shaders are validated through naga, the same compiler wgpu uses, so a typo fails in CI rather than at pipeline creation on a machine with a display.
  • The whole pipeline is exercised by tests that build a .kmap in memory, compile it through Cleave, load the result and play it. Every crate can pass its own tests and still not add up to a level you can walk around; that suite is where the seams show.

Sound

Six layers, separable on purpose.

kerosene-audio::wavSource .wav files to samples
kerosene-audio::compiled.kaud files to samples — what a shipped game reads
kerosene-audio::adpcmFour bits a sample, for the above
kerosene-audio::mixerVoices to a stereo buffer — no device, so it is testable
kerosene-audio::reverb, env, dspThe room and the air: what the world does to a sound on the way
kerosene-audio::deviceThat buffer to the sound card, behind a feature flag

Sound has a compiled form as well as a source one. timbre turns .wav into .kaud, which is a quarter the size, carries loop points and a peak, and records whether the sound may be positioned at all. The engine prefers it and falls back to the .wav when there is no compiled form — so a designer who has just dropped a file in hears it without running a build first, and a shipped game carries only the small one. See tools.md.

The split is the whole design. Everything that decides how a sound sounds — falloff, panning, resampling, voice limits — is arithmetic on buffers with no hardware in it, so a sound that pans the wrong way is a numeric fact rather than something to notice by ear on the third playthrough.

The mixer runs whether or not a sound card does. If audio only existed when a device opened, then everything about a game’s behaviour that touches sound — how many voices a trigger starts, whether a looping ambience got stopped — would differ between a machine with sound and one without, and only one of those would ever be tested. A missing device costs the last hop to the speakers and nothing else.

Playing something

play ui/click            # at the console
stopsound                # everything, now
snd_restart              # reopen the device, forget every decoded sound
volume 0.5               # master, archived
snd_reverb_preset hall   # hear a hall everywhere; empty to hear the map's rooms
snd_acoustics_debug 1    # say which room you are in as you move; 2 draws them

From a level: an ambient_generic entity, or a script.

play_sound("ui/click");                                  // heard flat
play_sound("door/move", Vector(256.0, 256.0, 64.0));     // from a place
play_sound("door/move", find_by_name("gate").origin, 0.5);
stop_sounds();

.ksnd — sound scripts

A level fires door/move, and what that is lives in a script rather than on the entity. The same indirection materials have, for the same reason: making every door in a game quieter should be one edit, not a hunt through a map.

sound
{
    "name"        "door/move"
    "file"        "sound/door/move.wav"
    "volume"      "0.9"
    "pitch"       "1.0"
    "loop"        "0"
    "distance"    "128"     // full volume within this radius
    "attenuation" "1.0"     // how fast it falls off past it; 0 never does
    "max"         "2048"    // not heard at all past this
}

Every .ksnd under scripts/ loads at startup, later files overriding earlier ones so a mod can change one sound without copying a file. A name nothing defines is taken as a path under sound/, so play ui/click.wav works before anyone has written a script.

How a sound is heard

  • Inside distance it is at full volume. Without that radius a sound at the listener’s own position divides by zero, and one a step away is much quieter than one underfoot — neither of which is how hearing works.
  • Past it, inverse-distance falloff scaled by attenuation. Zero carries forever, which is what music and a level-wide ambience want.
  • The last quarter of the range fades out, so a sound does not audibly switch off as you step past its limit.
  • Panning is constant-power: the two gains square-sum to one, so a sound crossing in front keeps the same loudness instead of dipping in the middle.
  • Gains ramp rather than jumping. A discontinuity in a waveform is a click, and a sound moving past the listener changes gain every block.
  • 64 voices at once, and the quietest gives way. A trigger firing every tick would otherwise stack thousands of copies of the same sound, which is both deafening and slow.

How a room sounds

A sound in a level is heard through the level: an empty concrete hall rings, a carpeted office is dead, a shout across a courtyard loses its consonants, and a door between you and a radio turns it into a murmur. None of that is placed. Resonance measures it from the map’s shape and materials at compile time, and the engine reads it back every tick.

The room. The map carries one record per room — a set of leaves that sound alike — saying how long sound lingers there in each of four bands, how soon the first echo returns, how open to the sky it is, and how loud the room is next to the sound itself. The mixer has one reverb, a feedback delay network with eight lines and a per-band loss in each loop, and every tick the engine hands it the record for the room the listener’s ears are in. Only the listener’s room: a sound in the next room over is heard through this room, which is how it works in a building. Near a doorway the two rooms’ figures are blended by distance, so stepping through one is a slide rather than a switch, and the reverb slides its own parameters over a fifth of a second on top, so nothing clicks.

Air. Every positioned sound is low-passed by its distance — 2 kHz at 4096 units, an octave lower for every 1233 beyond — because air really does eat the highs, and a shout across a field should not arrive with every consonant intact.

Walls. For every positioned sound the engine asks two questions. Is the sound’s cluster in the listener’s potentially audible set — the PVS grown by one room, which Umbra writes and this is the first thing to read? If not, no path exists and the sound is silent. Otherwise, how many of three lines from the ear to the sound — one straight, one to either side — are blocked? A wall blocks all three and the sound comes through 12 dB down with nothing above 800 Hz; a doorway lets one or two through and the sound comes round it thinned rather than cut. Being in the same room halves the effect: a pillar between two people in a hall is not a wall. Up to 24 sounds are re-traced each tick, the rest keeping last tick’s answer until their turn.

What the compiler looked at. Every surface’s absorption per band comes from its material — from $surfaceprop, or from an explicit $acoustics key (see formats.md). A designer who wants a room to sound a particular way regardless places an env_acoustic_override in it.

Convar
snd_reverbRoom reverb; 0 is dry everywhere
snd_reverb_presetroom, hall, cave or outdoor everywhere, for auditioning; empty uses the map’s
snd_occlusionWalls muffle, and no-way-through silences
snd_airDistance takes the highs out
snd_acoustics_debug1 reports the room as it changes; 2 also draws every room’s leaves, blue for dead through red for live, and a line to each sound, green where it gets through

Everything above is arithmetic on buffers with no device in it, like the rest of the mixer. The reverb’s decay times are checked in a test by measuring them off its own impulse response; the compiler’s are checked against Eyring’s formula for a concrete cube. A room that rings wrong is a number, not a feeling.

ambient_generic

The entity every level uses more than any other sound mechanism.

Key
messageWhich sound
healthVolume, 0 to 1 — named as Source names it
pitchPlayback rate; 2 is an octave up
radiusHow far it carries; 0 uses the script’s
loopingDefault on
Flag 1Starts silent, waits for PlaySound
Flag 2Heard everywhere — not positioned

Inputs: PlaySound, StopSound, Toggle, Volume. Output: OnPlay.

Formats

16-bit PCM WAV is the common case and what the sample content ships. Also read: 8-bit (unsigned, centred on 128), 24-bit, 32-bit PCM, and 32-bit float, mono or stereo, at any sample rate — anything not matching the device’s rate is resampled. WAVE_FORMAT_EXTENSIBLE headers are read through to the real format, which is what a modern recorder writes.

The decoder is written out rather than pulled in, because a decoder is somewhere an unexpected file should produce an error rather than a panic: a chunk claiming more bytes than the file holds is normal, not an attack, and takes what is there.

Building without audio

cpal needs ALSA headers on Linux (libasound2-dev). If that is not available:

cargo build --no-default-features -p kerosene-runtime

Everything except the last hop to the speakers still builds, and the whole mixer test suite still runs.

The console

The developer console opens with the key left of 1 (`, ~). It runs commands and sets convars the way Source’s does, and so does everything else that runs text: a key binding, autoexec.cfg, a +command on the command line, a layout’s command(), a map’s script.

  • name prints a convar and its help; name value sets it.
  • ; separates commands, // starts a comment, quotes keep a phrase together.
  • Tab completes a command’s name, and then its argument where that is a map, a save, a sound, a class or a convar. Pressing Tab again cycles.
  • Up and down walk history, which is kept between sessions in cfg/console_history.txt beside config.cfg.
  • find <text> searches names and help; help <name> explains one.

Cheats

A command or convar marked cheat works only with sv_cheats 1. Turning sv_cheats off puts every cheat convar back to its default, so a noclip left on does not outlive the permission for it. toggle, incrementvar and revert respect the flag like typing does.

Developer commands

CommandDoesCheat
godTake no damageyes
buddhaTake damage, never below 1 healthyes
notargetGo unnoticed; for a game’s AI to read (Engine::notarget)yes
noclipFly through wallsyes
killDie, god mode or not
give <class> [key value]...Spawn an entity where you standyes
getposPrint where you are as a setpos; setang line
setpos <x> <y> <z>Move thereyes
setang <pitch> <yaw> [roll]Face that wayyes
ent_fire <target> <input> [param] [delay]Fire an input at every entity with that name, else that class; !picker is what you are looking atyes
ent_create <class> [key value]...Spawn an entity where you are lookingyes
ent_remove [name]Remove what you are looking at, or everything with a nameyes
ent_info [name]Print an entity’s class, name, place, keys and outputs
host_timescale <n>Run the world n times as fast, 0.01 to 10yes
phys_spawn [model]Drop a physics prop in front of youyes
restartLoad the current map again from the start
maps [filter]List the maps there are
map <name>Load a map
save <name>, load <name>Save and load a game
screenshotSave the next frame under screenshots/ in the player’s directory
condump [name]Write the scrollback to a file in the player’s directory
revert <convar>Put a convar back to its default
host_writeconfigWrite config.cfg now rather than on exit
map_autoreload 1Reload the map, keeping your place, when it is rebuilt (play --watch sets it)

Video

r_fullscreen is 0 for a window, 1 for borderless fullscreen and 2 for exclusive fullscreen at the monitor’s own mode. It is saved like the other options, and the options menu has a switch for it. cl_fov takes 50 to 130.

The command line

game +map arena +sv_cheats 1     # any +command runs after config.cfg
game -w 1280 -h 720              # the window's size, over engine.kcfg's
game -fullscreen                 # or -windowed, over the saved r_fullscreen
game --headless 600 +map arena   # simulate without a window, then report
game --portable                  # keep saves and settings beside the content

--help (or -h on its own) lists the rest.

Where things are written

Everything the console writes – config.cfg, the history, screenshots, condump – goes into the player’s own directory (~/.local/share/<game>, %APPDATA%\<game>, ~/Library/Application Support/<game>), or with --portable into the content tree. Paths are virtual: nothing a command is given can reach outside that directory.

Configuration

The engine has settings that belong to no single map and no single project: which renderer to use, how big the window is, whether it syncs to the display. They live in one file at the top of the content tree:

engine.kcfg

The file always exists. The first program to look for it and not find it writes one with the defaults in it, so a fresh clone runs before anyone has edited anything, and a hand-edited file is read back on the next run.

The file

KeyValues, like everything else a person is expected to edit:

engineconf
{
    "renderer" "vulkan"
    "width"    "1280"
    "height"   "720"
    "vsync"    "1"
}

Every key is optional. A key that is absent falls back to its default, and a key that is wrong (a renderer nobody has heard of, a width that is not a number) logs a warning and falls back rather than refusing to start. A config must not be the reason the engine will not boot.

KeyDefaultMeaning
renderervulkanvulkan, metal, dx12, gl, or auto. The backend the window asks for.
width1280Window width in pixels.
height720Window height in pixels.
vsync11/0 (also true/false, yes/no, on/off).

The renderer

The renderer defaults to Vulkan. The engine asks wgpu for the Vulkan backend first, and only falls back to whatever else the machine has when Vulkan is not there — with a log line saying it fell back, so the choice is never silent.

auto means “whatever wgpu would have picked”, which on Linux is usually Vulkan and elsewhere is Metal or DirectX 12. metal and dx12 exist for the reverse case: a machine with several backends where someone wants to pin one for a reason of their own.

Every program that draws a window reads the same setting — the game, Chisel, and the tool windows — so the renderer choice is made once, in the file, not per program.

File formats

ExtensionWhatText or binaryWritten bySource analogue
.kmapEditable map sourcetext (KeyValues)Chisel.vmf
.kbspCompiled mapbinary, lump directoryCleave / Umbra / Resonance / Radiance.bsp
.kprtPortal graphtextCleave.prt
.kleakLeak tracetextCleave.lin
.kwalkNPC walkmapbinaryCleave(Source has no equivalent)
.ktexTexturebinary, resource containerAlchemy.vtf
.kmatMaterial sourcetext (KeyValues)Alchemy, by hand.vmt
.kmat_cCompiled materialbinary, resource containerAlchemy.vmat_c
texture.kcfgTexture set definitiontext (KeyValues)Alchemy, by hand(no equivalent)
.kmdlModelbinary, resource containerForge.mdl
.kdefEntity class definitionstext (KeyValues)the game, by hand.fgd
.kscrLevel script, UI scripttext (Rhai)by hand.nut (VScript)
.kuiUI layouttext (XML)by handPanorama .xml
.kcssUI stylesheettext (CSS subset)by handPanorama .css
.ksndSound scripttext (KeyValues)by handgame_sounds.txt
.kaudCompiled soundbinaryTimbre.wav (ADPCM)
.wav .flac .mp3Sound sourcesbinaryany audio tool.wav
.vaultContent archivebinaryVault.vpk

Text where a person edits or reviews it; binary where the engine loads it. The runtime reads no source format: a .kmat becomes a .kmat_c in the build, as a .png becomes a .ktex.

Every coordinate in every one of them is in kerosene units – one kerosene unit is one inch, a player is 72 of them tall. Angles are degrees, times are seconds, and Z is up.

KeyValues

The text format .kmap, .kmat, .kdef and the compiled entity lump all use.

world
{
    "classname" "worldspawn"
    "skyname"   "sky_kero"
    solid
    {
        "id" "1"
        // faces follow
    }
}

Two properties drive the implementation. Keys repeat — a world holds many solid blocks — so entries are an ordered list, never a map; a HashMap would silently eat brushes. And order is meaningful, so round-tripping a map through the editor does not reshuffle it and produce a noisy diff.

Backslashes are literal, so materials\dev\grid survives intact. Only the escapes the writer emits (\", \\, \n, \t) are resolved on read.

.kmap — editable maps

versioninfo { "formatversion" "1" }
world
{
    "id" "1"
    "classname" "worldspawn"
    solid
    {
        "id" "2"
        side
        {
            "id" "3"
            "plane"    "(0 0 0) (0 64 0) (64 64 0)"
            "material" "dev/grid"
            "uaxis"    "[1 0 0 0] 0.25"
            "vaxis"    "[0 -1 0 0] 0.25"
        }
    }
}
entity { "id" "9" "classname" "info_player_start" "origin" "0 0 32" }

Brushes are planes, not vertices. A solid is the intersection of its faces’ half-spaces. That makes convexity structural — you cannot author a brush with a hole in it — and it is what makes CSG possible. The cost is that a solid’s actual polygons only exist once something computes them.

Each plane is written as three points, listed clockwise as seen from the front of the face, so the normal is (p0 - p1) × (p2 - p1). That looks like the wrong cross product and is not: every brush face ever written to a .map-lineage file depends on exactly this ordering.

Faces carry no UVs. They carry two texture axes — world vectors a point is projected onto — which is what makes texturing feel the way it does in a brush editor: drag a brush and the texture stays locked to world space.

Any object can carry any key. An entity is a bag of "key" "value" pairs and always was; a solid and a side are too. The keys the format defines (plane, material, the axes, and so on) become typed fields; every other pair round-trips untouched, so a key the game or a compiler pass invents later, or one a person types into Chisel’s key-value editor, is never silently dropped. Cleave reads two on a solid: "detail" "1" compiles the brush as detail without tying it to detail, and "section" "name" puts it in a streamed section by name. Brush and face keys never reach the compiled map; a brush that should mean something to the game is tied to an entity, as in Source.

The editor’s own blocks. What Chisel needs to remember that the game does not is kept out of the properties so it can never leak into the compiled entity lump. Every solid and entity may carry an editor block, and the root may carry visgroups, any number of group blocks, and a cordon:

visgroups
{
    visgroup
    {
        "name"    "Cave"
        "id"      "40"
        "color"   "90 200 120"
        "visible" "0"
        "stream"  "1"
        visgroup { "name" "Pool" "id" "41" "color" "120 170 255" }
    }
}
solid
{
    "id" "2"
    side { ... }
    editor { "visgroupid" "41" "groupid" "50" "color" "220 30 30" "comments" "back stairs" }
}
group  { "id" "50" }
cordon { "mins" "-512 -512 0" "maxs" "512 512 256" "active" "1" }

VisGroups nest, and hiding a parent hides its children. A visgroup with "stream" "1" is also a section: Cleave tags its brushes with it and the engine streams the section’s geometry in and out around the player (see .kbsp below). Groups are flat: objects that share a groupid select together. "visible" "0" in an object’s editor block is quick-hide. Every one of these blocks is omitted when it would say nothing, so a map that uses none of them is byte-for-byte the map it was before they existed, and a reader that predates them skips them. Visgroup and group ids share the map’s one id space with entities, solids and sides.

Meshes. Beside its solids, the world may hold polygon meshes – Source 2 Hammer’s kind of geometry: points and the faces between them, any shape, open or closed, concave if it likes.

mesh
{
    "id" "40"
    vertices
    {
        "v" "0 0 0"
        "v" "0 64 0"
        "v" "64 64 16"
        "v" "64 0 16"
    }
    face
    {
        "id" "41"
        "v" "0 1 2 3"
        "material" "dev/grid"
        "uaxis" "[1 0 0 0] 0.25"
        "vaxis" "[0 -1 0 0] 0.25"
        "lightmapscale" "16"
    }
    editor { "visgroupid" "41" }
}

One vertex a line, so moving one is a one-line diff. A face lists vertex indices clockwise seen from the front, the same winding a brush face’s plane points and a compiled face use. Axes and lightmap scale default the way a brush face’s do. A face that is not flat and convex is cut into triangles when it compiles. Meshes are detail: Cleave draws, lights and collides with them – each face backed by an invisible 8-unit collision slab, so every trace in the engine sees it – but never lets them split the tree, seal the map or block visibility. Block a space out in brushes; dress it in meshes. Only the world’s meshes compile for now; a brush entity carrying one is warned about and left out.

.kbsp — compiled maps

A header (KROS, a version, a 24-slot lump directory) followed by flat arrays of #[repr(C)] records. Every record is padding-free, so loading a lump is a bounds check and a cast rather than a parse.

LumpHolds
entitiesKeyValues text, one block per entity
planesPairs: index n^1 is always the inverse of n
vertices, edges, surfedgesShared vertex positions and the ring indirection
facesRenderable polygons, with lightmap extents
nodes, leavesThe BSP tree
leaffaces, leafbrushesWhat each leaf holds
modelsModel 0 is the world; 1..n are brush entities
brushes, brushsidesConvex collision volumes
texinfo, texdata, texdata_stringsMaterials and their projections
visibilityThe run-length-encoded PVS, and the PAS beside it
lightingBaked lightmap samples
acousticsOne record per room: how long sound lingers there, per band
acoustic_leafsOne u16 per leaf: which room it is in
sectionsThe streamed sections: a name and bounds for each; section 0 is the world
face_sections, brush_sectionsOne u16 per face and per brush: which section it is in

The surfedge indirection. A face’s vertices are reached through a run of surfedges, each a signed index into the edge lump — negative meaning “walk this edge backwards”. Two faces meeting at an edge therefore share one edge record and one pair of vertex positions, which is what keeps their seam watertight no matter how the arithmetic rounds.

Plane pairs. Planes always live at indices 2k and 2k+1, so flipping one is index ^ 1. A node stores one plane index and its two children implicitly use the plane and its inverse.

Lightmap samples are ColorRgbExp32: three bytes and a shared exponent. That is what lets a baked lightmap carry values well above 1.0 — a bright sky, a lamp against a wall — in four bytes instead of twelve, and it is why the lighting can be tone-mapped at runtime rather than clipped at bake time.

Rooms. The two acoustics lumps took the directory’s two spare slots, so adding them moved no version. The first is an ACST header — the four band frequencies, then how many rooms and paths follow — and an 80-byte record per room: decay time per band, the absorption it was derived from, mean free path, pre-delay, openness, wet level, diffusion, a bounding sphere, volume, flags and a leaf count. The second is one u16 per leaf naming its room, 0xFFFF for a leaf in none. An empty acoustics lump is a map Resonance has not run on, the same way an empty visibility lump is a map without vis; the engine plays it dry and says so.

Sections. Version 2 added four slots for the streamed sections and kept one spare. A section is a visgroup the designer marked as streamed; Cleave numbers them in tree order after the world and tags every world face and brush with its number. The engine keeps the tree, the entities and the traces whole and streams each section’s render mesh, lightmap atlas and rigid-body hulls as the player moves. A compile always writes the lumps – one section, every face in it, for a map with no streamed groups – and a version 1 file is refused with a message to recompile.

Cubemaps. The last spare slot, so adding it moved no version either: a file from before it has the lump empty and reads as a map without probes. A KCUB header – version, face size, probe count – then each probe’s origin, then every probe’s six faces of RGB9E5 texels (three 9-bit mantissas and a shared 5-bit exponent: linear HDR in four bytes, on the same scale as the lightmap, 1.0 being a white surface lit to full). Radiance writes it from the env_cubemap entities, after baking the lightmaps the probes photograph. The face order and orientation are kerosene_bsp::cubemaps::face_basis – Kerosene’s own, since the renderer picks faces itself rather than using a hardware cube map. The next lump needs a version bump.

Every index in the file is validated at load. A dangling one becomes an out-of-bounds read deep inside the renderer, where the cause is invisible.

.kprt — the portal graph

Written by Cleave, read by Umbra.

VPRT1
<cluster count>
<portal count>
<points> <cluster a> <cluster b> (x y z) (x y z) ...

The winding’s own plane normal points toward the first cluster listed. Only portals between two non-solid leaves appear: sight does not travel through rock, so a portal with a solid side is not a portal.

.kwalk — the NPC walkmap

Written by Cleave, read by whatever drives NPC navigation. It is the answer to “where can NPCs go”, precomputed once at build time the same way the PVS precomputes visibility: the runtime asks a cheap question – is this point on a walkable face, and what is that face’s rule – instead of re-deriving it from the BSP.

A little-endian binary: a KRWL magic, a version, a face count, then one record per face holding its rule, its polygon and its normal. Cleave builds it from the world’s flat walkable faces – a face whose normal points up and wears ordinary geometry – and folds in each face’s walkmap rule chosen in Chisel:

  • allow – walkable if flat. The default; a floor that says nothing is one.
  • deny – never part of the walkmap, however flat.
  • avoid – walkable, but flagged for NPCs to route around.
  • always – part of the walkmap even if the face is not flat, for ramps.

Brush entities are absent on purpose: a door moves, and a static walkmap that said a closed door was open would send an NPC through it.

Navigation is built on top of the faces: kerosene-walk’s NavGraph links faces that share an edge and A*-searches them into a list of waypoints, with avoid faces given a movement-cost penalty so paths bend around them when a detour is not much longer. The runtime asks for a path the way it asks whether a point is walkable — cheaply, against data the compiler already produced.

The resource container

Compiled materials, textures and models share one layout, KRES, from the kerosene-resource crate. The rest (.kaud, .kbsp, .kwalk) still have a header of their own each, and move into it one at a time. A .ktex or .kmdl from before the container still loads: its old layout is exactly what the container’s DATA block now holds.

OffsetSizeField
04KRES
44Container version, 1
84Kind: four bytes naming the payload, KMAT, KTEX, KMDL
124Kind version
168Source hash: 64-bit FNV-1a of the source it was compiled from
244Block count
284Reserved, 0
3212 × countBlock table: tag, offset, length

Then the blocks, each on a 16-byte boundary. Three tags mean the same in every kind: DATA is the payload; REFS lists, as virtual paths, the other resources this one needs, which a packager follows; EDIT records the compiler, its arguments, and each input with its hash, so a tool can tell a stale file. A reader skips tags it does not know. All integers are little-endian. alchemy info prints a compiled material’s header and blocks.

.ktex — textures

The resource container, kind KTEX. Its DATA block is a 48-byte header (dimensions, format, flags, average colour) then the mip chain, largest first. Uncompressed RGBA8, RGB8 or R8.

Mipmaps, the average colour Radiance needs for bounce lighting, and sampling intent are all resolved at build time. Doing them at load costs startup on every run, and doing them well is not something to redo per launch.

.kmat — materials

lit
{
    "$basetexture"   "dev/grid"
    "$bumpmap"       "dev/grid_normal"
    "$roughness"     "dev/grid_rough"
    "$selfillummask" "dev/grid_emissive"
    "$ao"            "dev/grid_ao"
    "$metalnessmap"  "dev/grid_metal"
    "$surfaceprop"   "concrete"
}

That is the source. Alchemy compiles each one to a .kmat_c beside it (kind KMAT): the shader is resolved and the text parsed at build time, and the textures it names go in the REFS block. The engine loads the .kmat_c. A project whose content predates compiled materials still runs from its .kmat files, with a warning for each, until its next build.

The block name is the shader — lit, unlit, sky, water, ui. A small closed set, because every one is a real code path; an open-ended string would just be a way to fail at draw time instead of load time.

Geometry references materials, never textures, so retexturing a level or making every metal surface reflective is one file change. Unknown parameters round-trip rather than being dropped: a game will invent keys the engine has never heard of.

The six texture parameters above are the ones the renderer samples. Every one but $basetexture is optional and absent by default, and a material that names none of them renders exactly as it would have before they existed — the shader branches on a per-material word saying which are real, so there is one pipeline rather than a variant per combination.

ParameterWhat it does
$basetextureThe colour. Sampled through an sRGB view.
$bumpmapTangent-space normals. The basis comes from the face’s texture projection, so it agrees with the UVs by construction.
$roughnessRed channel, 0 mirror to 1 matte. Drives the specular term; with no map (and no $roughnessfactor) a dielectric is fully rough and gets none.
$selfillummaskWhat the surface emits regardless of light reaching it. Added in linear HDR before tone-mapping, so it rolls off like a lit highlight.
$aoBaked occlusion, multiplied into the lighting. Darkens what the surface shadows itself, which the lightmap’s luxels are far too coarse to see.
$metalnessmapRed channel, 0 dielectric to 1 bare metal. Metal has no diffuse colour; its base colour tints its reflection instead.

Two scalars go with the maps, the way glTF’s factors do: each multiplies its map, and with no map it is the whole answer.

ParameterDefaultWhat it does
$metalness0, or 1 with a $metalnessmapHow metallic. "$metalness" "1" is a plain metal with no texture.
$roughnessfactor1Scales roughness. $metalness 1 with $roughnessfactor 0.1 is polished chrome.

Shading is GGX with height-correlated Smith and Schlick Fresnel – the model Source 2 and every other current engine settled on. A lightmapped surface reflects its nearest visible env_cubemap, blurred by roughness; one with no probe reflects an even glow the brightness of its own lightmap.

$surfaceprop is the physical type the surface is made of — concrete, metal, wood, and so on. It has two consumers. At runtime a trace that stops against a face reports the face’s material, and the engine reads that material’s $surfaceprop to choose a footstep sound (footstep/<type>/n). At compile time Resonance reads it for how much sound the surface soaks up, per band, from a table of the published figures for each type: carpet eats the highs, concrete eats almost nothing, snow eats almost everything. Unknown types parse to other rather than failing, so a game’s own surface names still work without the engine shipping them.

$acoustics overrides that, for the texture that looks like concrete and is meant to be acoustic tile: either four absorption coefficients for 125, 500, 2000 and 8000 Hz — "0.02 0.14 0.60 0.65" — or the name of a surface type to borrow them from, "carpet", leaving the footsteps as they were.

.kmdl — models

The resource container, kind KMDL, whose REFS block names every material the model draws with. Its DATA block is a 64-byte header, then vertices, indices, meshes, bones and a string table.

One model holds several meshes, each with its own material, because a single object routinely uses more than one. Splitting by material at compile time means one draw call per mesh instead of sorting at runtime.

Vertices carry four bone influences whether or not the model is skinned. Eight wasted bytes per vertex on a static prop buys one vertex layout, one shader path and no branch in the hot loop.

Bones are listed parents-first, so a single forward pass can build world transforms with no recursion and no sorting. A bone’s rest transform is its bind pose – the pose the mesh was skinned in – which is what the renderer inverts to move the mesh with the bones.

Animations (version 2). After the string table: one 16-byte record per clip – name, frame count, frames per second, a loop flag – then every clip’s keys, frame after frame, one 28-byte BoneKey (translation, rotation quaternion, both relative to the parent) per bone per frame. Clips are resampled at a fixed rate by Forge, so playing one is two lookups and an interpolation per bone. A version 1 file – every static prop compiled before animations existed – still loads, as a model with none.

Triangles are wound counter-clockwise as seen from the front – the convention the GPU renderer culls by, and the opposite of .kmap faces, which are clockwise. Forge takes an OBJ’s winding as-is (the axis remap preserves handedness), so the OBJ must already be counter-clockwise: a face wound the other way compiles and collides fine but renders inside out.

texture.kcfg — texture sets

A single PNG is not a surface. A surface is a colour, the bumps in it, how rough it is, what it glows with and where it self-shadows — five images that belong together and are useless apart. So a texture can also be a folder, under content/textures/:

content/textures/Walltextures/variant1/
    basecolor.png
    normal.png
    roughness.png
    emissive.png
    ao.png
    texture.kcfg      (optional)

It names itself from where it sits: Walltextures/variant1 compiles to Walltextures_variant1, and each map takes a suffix — _normal, _rough, _emissive, _ao. The path is already unique and already describes the thing, so restating it in a file would only be a second answer that can disagree with the first. Alchemy writes the matching .kmat wiring up whichever maps are there, once, and never overwrites one afterwards.

Filenames are matched generously (albedo, diffuse, col; nrm, bump; rough; glow, selfillum; occlusion) because those names come out of whatever tool baked them, and rejecting albedo.png because the engine wanted basecolor.png would be a rule that exists only to be tripped over. A folder with no base colour is not a set: the other maps modulate a colour, and with nothing to modulate they would compile into a material that draws the missing-texture checkerboard.

texture.kcfg is how you overrule any of that. Every key is optional — the point of the folder convention is that the common case needs no file at all:

texture
{
    "name"        "brick_red"     // instead of the folder-derived name
    "normal"      "weird_bumps.png"  // an image discovery would not find
    "shader"      "lit"
    "surfaceprop" "brick"
    "clamp"       "0"
    "point"       "0"
    "translucent" "0"
}

A key naming a file that is not there costs that one map, not the texture; a config that does not parse costs the config, not the folder. Losing a whole surface to a stray brace would be a poor trade.

To make one without editing anything by hand:

kerosene-tools alchemy new-texture Walls/brick     --basecolor brick.png --normal brick_n.png --surfaceprop brick

The images are copied in under their canonical stems, so the artist’s originals stay where they are and the content tree stays self-contained.

The loose art/*.png tree is unchanged and still compiles one image to one texture — right for a tool texture or a skybox, which have nothing to be a set with.

The developer texture set

Not a format, but content worth knowing the provenance of: alchemy dev-textures writes content/art/dev/ and content/art/tools/ along with their materials. The PNGs and .kmat files it writes are committed; only the compiled .ktex is not, because that is a build artefact.

dev/ are 256x256 checkerboards. At the default texture scale of 0.25 world units per texel that covers 64 ku, so the 4x4 grid on them reads as 16 ku cells – the size most brushwork is done at – with a fainter 4 ku subdivision over it. The checker is what makes a stretched texture obvious: a square that is not square is visible from across a room, where a stretched grid line is not. Only dev/measure carries numbers, because a world-aligned projection mirrors on opposite walls and text in a tiling texture therefore reads backwards on half of them.

tools/ are 128x128, flat, hatched, and labelled with their own name. A colour alone does not tell clip from playerclip at a glance.

They are generated because they are defined by numbers – this grid is 16 units, that colour means “blocks players” – and a definition that lives only in a PNG is one nobody can read or review. They are committed anyway, because a fresh clone with no textures in it is a fresh clone that looks broken: having a generator is a reason to keep one, not a reason to leave its output out of the repository. The generator reports what it changed and what was already correct, so re-running it on an unchanged tree is a no-op and a diff against it is meaningful.

Chisel runs the generator, and the batch compile after it, before it finishes loading. So a clone with the .ktex files missing – which is every clone, since they are build artefacts – still opens an editor with textures in it.

A test checks the set against the compiler’s tool-material table in both directions: offering a tool material the compiler treats as world geometry would silently wall off a doorway.

.kproj — the project file

Where a project says, rather than implies, where its content is.

project
{
    "name"     "My Mod"
    "content"  "content"
    "startmap" "mm_intro"
}

content is relative to the file, so a project can be cloned or moved anywhere and still be right. Every key is optional – an empty project { } block still marks a directory as a project, with content defaulting to a content/ directory beside it and then to the directory the file is in, which is the shape of a shipped game where the project file sits in the folder it describes. startmap is what kerosene loads when nothing else says which; a project that is a library of maps simply omits it rather than having one invented.

It exists because everything else about locating content is inference. The tools climb the directory tree looking for something shaped like a content root, which works, and is why a fresh clone needs no setup, and is still a guess – one nobody could overrule short of passing --content to every tool every time. A project file is the answer that wins.

Within any one place searched, a project file beats an inferred root even one found closer down: a stated answer that loses to a guess is not an answer. Between places, nearness still decides. The full order is in tools.md.

The content tree is created, not required

The directories under content/ — maps, materials, art, textures, models, sound, scripts — are made on first run by whatever starts first, the engine or the toolset, and any that are missing are filled in. A tree with one of them absent does not announce itself; it looks like whichever tool went looking for it being broken, which is the same class of problem the content-root search was written to end.

kerosene-tools init [dir] does it deliberately, writing a .kproj and the tree beside it. It is safe to run on an existing project: the project file is left exactly as it is, and only missing directories are created.

A project that wants a different layout says so, with repeated dir keys, and is believed — the same bargain as content:

project
{
    "content" "content"
    "dir"     "maps"
    "dir"     "materials"
    "dir"     "levels"
}

A project with no dir key gets the standard set, which is every project written so far. A dir naming a path outside the tree is ignored with a warning: the list is text somebody typed, and it does not get to create directories beside the project.

.kdef — entity class definitions

What an editor needs to know about the game’s entities: for each class, the keys it reads, the inputs it answers to and the outputs it fires. The engine never reads this — an entity there is a bag of whatever keys the map carries, and that is deliberate. The file exists so that a person placing a door is shown that speed and lip are things, instead of an empty panel.

It is the FGD relationship, and it is what keeps Chisel a separate program from the game: the editor reads the game’s file rather than linking its code.

base
{
    "name" "Entity"
    key   { "name" "targetname" "label" "Name" "type" "target_source"
            "help" "What other entities call this one." }
    input { "name" "Kill" "help" "Remove this entity from the map." }
}

class
{
    "name" "door"
    "kind" "brush"
    "base" "Entity"
    "help" "A brush that slides open and shut."
    key {
        "name" "spawnflags" "label" "Flags" "type" "flags" "default" "0"
        choice { "value" "1" "label" "Starts open" }
    }
    key    { "name" "speed" "type" "float" "default" "100" "help" "Units per second." }
    input  { "name" "SetSpeed" "parameter" "units per second" }
    output { "name" "OnFullyOpen" }
}

kind is point, brush or any, and decides which menu a class appears in. A class may name several base blocks; their keys, inputs and outputs come first, and a key the class redefines wins. Key types are string, int, float, bool, vec3, angles, color, target_source, target_destination, material, model, choices and flags — the set is closed, so an unrecognised type is an error at load rather than a text box at edit time.

A class may also declare helpers: what the editor draws for it beyond its marker – the FGD’s studio(), lightcone() and sphere(). They change nothing in the game.

class
{
    "name" "light_spot"
    "base" "Entity" "base" "Point" "base" "Angles"
    helper { "type" "lightcone" }
    helper { "type" "lightradius" }
}
class
{
    "name" "ambient_generic"
    helper { "type" "sphere" "radius" "radius" "color" "120 230 220" }
}
typedrawsparameters
modelthe entity’s model, posed by its angleskey (default model)
lightradiustwo spheres: where the light lights a surface fully, and where it has all but faded – from _light and the _*_attn keys, by the falloff Radiance uses
lightconea cone along the angles, from _cone and _inner_cone (half-angles), honouring the pitch override
spherea sphereradius, color
frustumwhat a camera seesfov (90), length (256)
directionan arrow along the angleslength (48)
linea line to every entity the key nameskey (default target)
recta flat rectangle facing along the angles: a panel, a decalwidth, height

A numeric parameter is a number or the name of a key: "radius" "radius" reads the entity’s own radius, "radius" "256" is always 256. Helpers are inherited from bases like keys, and a class’s own helper of a type replaces an inherited one. Two need no declaring: a class with a model-typed model key is drawn as its model, and every target_destination key draws a line to what it names. An unknown helper type is an error at load, naming the class.

Files are merged in sorted path order and a later definition of a class replaces an earlier one, so a mod can drop its own file beside the game’s.

A default is what the game assumes when a key is absent. Chisel shows it greyed rather than writing it into the map, so a .kmap only carries the keys someone chose — which is what makes a diff between two saves readable.

content/kerosene.kdef describes the sample game, and a test in kerosene-game checks it against the class registry in both directions: an input the game handles and the file does not offer is a build failure, and so is an input the file offers that nothing handles.

.kscr — level scripts

A map’s script, loaded automatically when the level starts if it is named after the map. The language is Rhai; the API, the hooks and the limits are in scripting.md.

fn on_map_start() {
    for door in find_by_class("door") {
        print(`${door} at ${door.origin}`);
    }
}

Nothing about the format is special — it is source text the engine hands to a VM. It is listed here because it is content the engine loads by name and packs into a .vault with everything else.

.ksnd — sound scripts

What a sound name means: which file, how loud, how far it carries. The format and the model behind it are in audio.md.

.vault — content archives

[ header 40 bytes ][ directory, sorted by path ][ data blob ]

Each directory record is path_len:u16, path, crc32:u32, offset:u64, size:u64. Paths are stored already normalised, so a lookup is a binary search with no per-query allocation.

Reads are checked against the stored CRC. A corrupt archive — a truncated download, a bad copy — is far better caught at the read than as a garbled texture three subsystems later.

Output is byte-for-byte reproducible: insertion order does not leak into the file, which matters for patching and for diffing releases.

Virtual paths

Asset paths arrive in every spelling — Materials\Dev\Grid, materials/dev/grid, ./materials//dev/grid — and all resolve to one key: lowercase, forward slashes, no redundant parts.

Paths that climb above the search root are refused, not clamped. Map and material files are untrusted content that ships between users, and ../../../../etc/passwd in a $basetexture must not resolve to anything.

Search paths are consulted in the order they were added, first hit wins, and loose files shadow packed ones — so during development you drop a file next to a shipped archive and it takes effect immediately, with no repack.

Licensing

Kerosene is licensed under the GNU General Public License, version 3 or later, with the Kerosene Exception — additional terms under the GPL’s section 7. Both texts ship in the repository, LICENSE and LICENSE-EXCEPTION; Cargo.toml declares license = "GPL-3.0-or-later WITH AdditionRef-Kerosene-Exception-1.0", and every source file carries the matching SPDX-License-Identifier line.

AdditionRef- is SPDX’s prefix (from version 2.3) for an addition to a licence that is not on SPDX’s own list of exceptions, which the Kerosene Exception is not. It is what makes the expression one that tools can read: Cargo, cargo-deny and anything else that audits a dependency tree see the GPL and the Exception — including its permission to link Kerosene statically — rather than refusing the line or reading it as the GPL alone. Earlier versions spelled it LicenseRef-Kerosene-Exception-1.0, which SPDX does not allow after WITH; the terms are the same.

NOTICE states what that means on one page; this document explains the reasoning, walks the exception clause by clause, and then does the dependency audit.

I am not a lawyer and none of this is legal advice. It is an accurate inventory plus the reasoning behind the choice, so that you or an actual lawyer can move quickly.

What the licence means here

The GPL on its own would make any program that links Kerosene a covered work: a game built on it would have to be GPL too. The exception changes that, and only that.

A game built on Kerosene is yours. The exception’s section 1 is an additional permission: link Kerosene, statically or dynamically, into a game and convey the game under terms of your choice — closed, commercial, whatever you like. Your code, levels, scripts and assets are yours. The conditions:

  • the engine part of the game — Kerosene, plus any change you made to it — stays under the GPL with the exception, with its source available as the GPL requires;
  • the game carries a notice that it is built with Kerosene and says where the engine source is (kiln --ship writes it);
  • the game shows an attribution screen when it starts — “Built with Kerosene”, before or together with its first interactive screen. Brief and dismissable is fine; disabled or hidden is not. A program with no graphical interface prints the same line instead;
  • if the engine is modified, the whole modified engine is published as source where anyone can obtain it free of charge, not only offered to the people who receive the game.

A modified engine is a modified Kerosene, whatever it is called and wherever the changed files are put — a copy of movement.rs inside your game crate is still the engine. Convey one, alone or inside a game, and section 2 of the exception asks that it say plainly that it is “modified from Kerosene” or “built from Kerosene”, name the version or commit it diverged from, and link to Kerosene’s source — in its own notice, in the notice of every game built on it, and on the attribution screen of every game built on it. It must not present itself as the original or as endorsed by this project, and it must carry the attribution-screen requirement forward so that games on the fork still show where they came from.

The Kerosene name is not granted by the licence. A fork may call itself Kerosene-something only if it does the above and publishes its complete source, as one engine rather than as changed files, under the same terms. Any other name owes only what the GPL and the exception say.

The tools — Chisel, the compilers, Kiln — are under the same terms. The linking permission covers them too, so a game’s own mygame-tools binary built on kerosene::tools ships the same way the game does. An unmodified tool distributed on its own is a plain GPL binary: ship it with its source.

Why the GPL with an exception, and not the LGPL or the MPL

Kerosene began under LGPL-3.0-or-later, then offered MPL-2.0 as an alternative arm. Each had the flaw the other fixed:

  • The LGPL’s mechanism is relinking. It assumes a user can swap the library inside a program for their own build, and Rust’s default static linking makes that a chore for anyone shipping a closed game — C-ABI plumbing, or an object-file distribution step cargo does not do. None of it bites at development time; the moment a binary leaves the door it demands work.
  • The MPL’s unit is the file. It has no linking stage, so it asks nothing of a closed game — but it also asks nothing of a modified engine beyond the files that changed, and nothing at all of a fork that presents itself as something new. The derivative-engine policy had to live outside the licence, as a condition on using the name.

GPL §7 gives a licensor the two levers that fix both at once. An additional permission may be granted on any conditions the licensor likes, because a recipient who declines the conditions simply has no permission and the plain GPL applies. That is the linking exception: it does what the LGPL does for a game — keep your code, keep the engine open — without the relinking clause, and its conditions carry the policy the MPL could not. And §7 (b)–(f) allow a short list of additional requirements — preserved notices, marking of modified versions, no misrepresentation, limits on the licensor’s name — that cover the rest of the policy and that a recipient cannot strip.

Nothing in the exception is a “further restriction” in §7’s sense. Every requirement is one the section names, and every condition sits on the permission rather than on the GPL.

The exception, clause by clause

ClauseWhat it encodesWhy it is allowed
§1You may link Kerosene into a game and ship the game under your own termsAn additional permission (§7, first paragraph)
§1(a)The engine part, with your changes, stays GPL with sourceA condition of the permission
§1(b)The game carries “built with Kerosene” and a source pointerA condition of the permission; also §7(b)
§1(c)A modified engine’s whole source is public, not only offered to recipientsA condition of the permission — the GPL alone could not require publication
§1(d)The game shows the attribution screenA condition of the permission
§2(a)Copyright notices, NOTICE, and the built-with statement are preserved§7(b): preservation of legal notices and author attributions
§2(b)A modified engine says “modified from Kerosene”, the version diverged from, and a link — in its notice, its games’ notices and their attribution screens; it is not presented as the original§7(b) attribution; §7(c) marking of modified versions and no misrepresentation
§2(c)The authors’ names are not used for publicity§7(d)
§2(d)“Kerosene” as a fork’s name only with §2(b) and the whole source published§7(e): declining to grant trademark rights, on stated conditions
§2(e)The attribution screen, at start, before or with the first interactive screen; inherited by every fork§7(b): an author attribution in the Appropriate Legal Notices the GPL’s §5(d) already has interactive programs display

A recipient may remove the §1 permission from their copy, as §7 allows, and be left with the plain GPL plus §2. Nobody can remove §2.

The engine does not yet draw the attribution screen for you: a game adds one through Game::ui today, and an engine-drawn one that satisfies §2(e) by default is a listed gap in Missing features. The obligation is on the shipped program either way.

Third-party dependencies

325 distinct crates in the workspace dependency graph (normal edges, all targets). Two facts matter more than any list:

  • Every licence in the tree is GPL-3.0-compatible. Apache-2.0 is compatible with GPL version 3 (not 2, which is one reason the project is 3-or-later), and MPL-2.0 lets its files be combined into a GPL work through its own §3.3. A copyleft dependency is ordinary here rather than something to quarantine.
  • No GPL crate appears at any depth, and no LGPL or AGPL crate either. Kerosene is the only whole-work copyleft in a game built on it, and the exception is what decides what that asks of the game. Nothing in the tree adds an obligation of its own beyond a notice.

The only copyleft-licensed dependencies are MPL-2.0:

ReachesShips in a game
smartstring (MPL-2.0+), via rhaithe engine
Symphonia, six crates (MPL-2.0)timbre only
LicenceCrates
MIT OR Apache-2.0 (and orderings/spellings of it)442
MIT55
Unicode-3.036
Zlib OR Apache-2.0 OR MIT (either order)34
Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT12
Unlicense OR MIT10
Apache-2.0 (only)10
ISC5
(MIT OR Apache-2.0) AND Unicode-3.04
BSD-2-Clause / BSD-3-Clause / BSD-* OR ...9
Zlib, CC0-1.0, 0BSD OR MIT OR Apache-2.0, Apache-2.0 AND MIT4
(MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.01
MPL-2.0 / MPL-2.0+7

(Counts are per dependency edge, so a crate pulled in by several others is counted more than once. Regenerate them with the command below; the table is a snapshot and the command is the truth.)

Regenerate with:

cargo tree --workspace --edges normal --prefix none --format '{p}|{l}'

The MPL-2.0 rows above are smartstring and Symphonia’s six crates.

box3d-rust (MIT) sits in the plain-MIT bucket and backs kerosene-physics’s PhysicsWorld, the rigid-body simulation. It is a pure-Rust port of Erin Catto’s Box3D with no dependencies of its own and no build script, chosen over a Jolt FFI wrapper specifically so the tree never has to vendor a C++ physics library, carry bindgen output, or require a C++ toolchain to link it.

The steamworks crate (MIT OR Apache-2.0) and the steamworks-sys crate under it are behind the opt-in steam feature of kerosene-platform, which is off in every default build. What they bind, and carry, is Valve’s Steamworks SDK: its headers and its redistributable steam_api libraries. The SDK is not free software, and is distributed under the Steamworks SDK Access Agreement.

That is why it is a feature and not a dependency. The Kerosene tree vendors none of the SDK, and nothing in the default build links it. The table above describes the default build, so the SDK is not in it.

A game that turns the feature on is a Combined Work under the Exception, with the SDK as an Independent Module, which §1 permits. The engine part stays GPL with its source available; the SDK stays under Valve’s terms. The GPL alone would not allow the combination, and this is one of the things the Exception is for. kiln --ship --steam writes a notice saying all of this into the shipped README.txt.

The two MPL-2.0 dependencies

What MPL-2.0 asks: file-level copyleft. A shipped, unmodified binary carrying MPL-2.0 code owes a notice — the licence text and a statement of where the MPL source lives. Modified MPL files must be released as source under MPL-2.0. It reaches nothing else.

smartstring 1.0.1 (MPL-2.0+)
└── rhai
    └── kerosene-script → kerosene-engine

Scripting pulls it in, so it is inside kerosene and inside every game built on it. A shipped game therefore carries MPL-2.0 code, and the obligation that travels with it is the notice — the same shape as the OFL and Ubuntu font obligations that egui brings, and satisfied the same way. kiln --ship writes that notice into the distribution’s README.txt, so a build made with it is compliant without anyone remembering to be.

gltf  gltf-json  gltf-derive  serde  serde_derive  serde_json

MIT or Apache-2.0, all reached only by forge, which reads .gltf and .glb sources. The engine reads the .kmdl Forge writes and never links these, for the same reason as Symphonia below: a tool is never shipped.

symphonia  symphonia-core  symphonia-common
symphonia-metadata  symphonia-bundle-flac  symphonia-bundle-mp3

All MPL-2.0, all reached only by timbre, which reads FLAC and MP3 sources. kerosene-audio decodes what a player’s machine loads and is hand-written for that reason; Timbre is a compiler that runs on the machine making the content, and kiln --ship refuses to put a tool in a distribution at all. So no game ever ships these, and no game developer inherits anything from them. Alchemy made the same call first, pulling in image for PNG and JPEG while the engine reads only .ktex.

Admitting them was a choice with poor alternatives. There is no maintained, permissively licensed, pure-Rust MP3 decoder: the options were Symphonia, a C library through bindings — which would put a C++ toolchain in the build and wreck the cross-compilation story kiln --ship depends on — or writing an MP3 decoder by hand, which is several thousand lines of solved problem. FLAC alone could have used claxon (Apache-2.0); MP3 forced it.

The four that actually matter

Apache-2.0-only crates.

winit  cpal  ab_glyph  ab_glyph_rasterizer
owned_ttf_parser  spirv  codespan-reporting  gethostname

Apache-2.0 decided the GPL version: it cannot be bundled under GPL-2.0, but it can under GPL-3.0, and Kerosene is 3-or-later. The list is kept here because it is the set of crates whose “Apache-2.0 only” status is worth knowing if anyone ever forks with different terms in mind.

Bundled fonts — epaint_default_fonts and egui-phosphor. egui ships default typefaces under the SIL Open Font Licence 1.1 and the Ubuntu Font Licence 1.0, and egui-phosphor (MIT OR Apache-2.0) embeds the Phosphor icon font, itself MIT. Those travel inside any binary linking egui, which here means the toolset and the engine’s debug overlay. Both licences permit redistribution; both require their notices to be preserved, and OFL forbids selling the fonts on their own and imposes a Reserved Font Name rule if you modify a font. Shipping them unmodified inside an application is exactly the intended case. If you publish binaries, ship the font licences alongside them.

hexf-parse is CC0-1.0. A public-domain dedication, so it imposes nothing — but some corporate policies flag CC0 because it explicitly does not grant patent rights. Irrelevant for a hobby or open-source release; worth knowing if this ever goes near a company’s legal review.

dpi is Apache-2.0 AND MIT — conjunctive, not a choice. Both sets of terms apply. Both are permissive, so this changes nothing practical.

Content in this repository

Everything under content/ was made for this project:

  • content/art/**.png — flat-colour and procedural developer textures (256x256 and 128x128), authored here. No metadata, no third-party source.
  • content/art/props/crate.obj — hand-written vertex list.
  • content/maps/kerosene_room.kmap — emitted by crates/kerosene-map/examples/starter_room.rs from kerosene_map::starter, i.e. generated by code in this repository.
  • The demo level, which used to be here, is in its own repository, kerosene-demo, made the same way.
  • content/materials/**.kmat — hand-written KeyValues.

Nothing was extracted from, decompiled from, or converted out of any game. Every compiled artefact (.kbsp, .ktex, .kmdl, .kprt, .vault) is a build output, reproducible with scripts/build-content.sh, and is not committed.

Provenance of the algorithms

This is the part worth being honest about, because it is the only place the question has any teeth.

Kerosene implements techniques that are decades of published graphics research: BSP trees for solid geometry and draw order (Fuchs/Kedem/Naylor 1980; Naylor/Amanatides/Thibault 1990), portal-based PVS precomputation (Teller’s 1992 dissertation), radiosity lightmapping (Goral et al. 1984), Sutherland–Hodgman clipping (1974), Gribb–Hartmann frustum extraction. None of that is anyone’s property.

id Software’s Quake tools are a famous implementation of several of them, released under GPL-2.0, and Valve’s Source engine descends from that lineage. Some modules here follow that implementation’s structure closely enough that a reader would recognise it:

ModuleFollows
kerosene-math::winding::Winding::splitClipWindingEpsilon
cleave::tree::select_splitSelectSplitSide’s scoring
umbra::flow::clip_to_separatorsClipToSeperators
kerosene-physics::movementSource’s gamemovement solver
kerosene-map::texture base axesQuake’s baseaxis table

All of it was written from scratch in Rust, against descriptions of the algorithms rather than by transcription; the naming, types, error handling, data layout and tests are this project’s own. US copyright does not extend to “any idea, procedure, process, system, method of operation” regardless of how it is described in a work (17 U.S.C. §102(b)), and the constants that are copied — an epsilon of 0.1, an air-speed cap of 30, a table of six axis vectors — are facts about a behaviour being deliberately matched, not creative expression.

That is the reasoning. It is not a guarantee, and the honest framing is: following the structure of GPL-2.0 source is lower risk than copying it and higher risk than never having read it. Every place it happens is named in a source comment and in the table above, so nothing is hidden.

The choice of licence does not change this, and it is worth being explicit about why, since picking a licence might look like an answer to it. It isn’t. A licence governs what this project grants downstream; it cannot clear anything upstream. Being GPL-licensed ourselves is about combining code going forward, not about taking code out of a GPL-2.0 work — GPL-2.0 code could not be moved under GPL-3.0-or-later with an exception in any case, because the GPL does not permit relicensing its code without the author’s permission. What actually lowers the risk is what the project already does: ship no Valve or id content, define formats theirs cannot read, and state provenance plainly.

Names and trademarks

“Valve”, “Source”, “Hammer”, “Quake” and the rest appear throughout the docs and comments. That use is nominative — naming someone else’s product in order to say what a thing here is analogous to. It is the same use as “works like Photoshop”. It is not branding: nothing in this project is named after a Valve product, no Valve mark appears in a binary’s name, icon or UI chrome, and NOTICE disclaims affiliation explicitly.

The file formats were renamed for exactly this reason. .vmap, .vmat, .vmdl and .vtex — the extensions this project originally used — are Source 2’s real extensions, and .lin is the leak pointfile Hammer loads. They are now .kmap, .kmat, .kmdl, .ktex and .kleak, and the two binary magics that named Valve formats (VTEX, VMDL) are now KRTX and KRMD.

The engine’s own name changed once too, for a plainer reason: it was VoidEngine until August 2026, and an unrelated engine built on id Tech 6 already had that name. Nothing was shared with it and nothing needed clearing; the collision was simply a collision. The extensions moved with the name — .voidmap became .kmap, and the magics VOID, VOTX, VOMD and VOAU became KROS, KRTX, KRMD and KRAU — so a file written by either version says which it came from.

Consequences of the choice, in one place

For contributors. Patches to Kerosene’s own files are offered under the same terms — GPL-3.0-or-later WITH the Kerosene Exception. That matters more than it would for a plain GPL project: the exception is an additional permission, and a contribution offered under the GPL alone would carry no such permission, so the file it touched could no longer be linked into a closed game. There is no CLA; the SPDX line in every file records the terms, and sending a pull request is accepting them.

For someone forking the engine. The whole modified engine is under the GPL with the exception. It says “modified from Kerosene”, names the version or commit it diverged from, and links to Kerosene’s source — in its notice, in the notice of every game built on it, and on their attribution screens. Ship a closed game on it and the fork’s whole source is published where anyone can get it. Call it Kerosene-something and the same publication is the price of the name. The project’s preference, every time: contribute the change back as a pull request and use the updated engine, so the fix exists once rather than once per fork. Forks and derived engines has the three cases side by side.

For someone shipping a game. Your game is yours. You owe the licence texts, the notice with a source pointer, and an attribution screen at start; kiln --ship writes the first two and the Shipping checklist has the third. There is no relinking clause and nothing resembling one.

For someone shipping the tools. Chisel and the compilers are under the same terms. A game’s own tools binary is a combined work like the game and ships the same way. A tool on its own is a GPL binary: distribute the source with it.

Fonts, again, because it catches people. Any binary linking egui — the toolset, and the engine’s debug overlay — carries OFL-1.1 and Ubuntu-Font-1.0 typefaces inside it, and the toolset carries the MIT Phosphor icon font too. All three licences are satisfied by shipping their notices alongside the binary. None conflicts with the GPL, because the fonts are data travelling with the program rather than part of it.

And smartstring, for the same reason. It is MPL-2.0 and it is inside the engine, so a shipped game carries it and owes its notice. kiln --ship writes that too.

What the licence does not do. It does not make the provenance question above go away, in either direction. Copyleft is a statement about what you grant downstream; it is not a clearance of anything upstream. What actually keeps this project clean is what it already does: ship no Valve or id content, define formats theirs cannot read, and name every module that follows the structure of published work.

Missing features

An inventory of what Kerosene does not have yet, in four parts: what the code reveals that the docs did not admit, what the docs already admit, what is absent by subsystem, and what the mainstream engines have that is worth borrowing – and what is not. Read it as a candidate roadmap, not a bug list: several of these are deliberate design choices (a flat entity list, a closed shader set) and several are simply unstarted. The last section puts an order on it.

For which of these absences actually matter – and which genres the engine is shaped to serve – see positioning.md. Everything here is weighed against that document’s answer: a movement shooter or an immersive sim first, geometry-heavy and animation-light.

1. What the code says that the docs did not

These were found by reading the tree rather than the README, and each is either a correctness gap or a thing whose absence undercuts a claim made elsewhere in these docs.

  • The view is not interpolated between ticks. Fixed: the host reads Engine::interpolation_alpha() and takes the view angles from the latest input rather than the last tick.
  • No continuous integration. Fixed: .github/workflows/ci.yml runs fmt --check, clippy -D warnings and the tests on Linux, and builds on Windows and macOS.
  • No panic hook, no crash log. Fixed: kerosene_console::install_crash_handler writes crash.log beside the binary with the panic, a backtrace and the last 64 log lines.
  • No demo recording. The most conspicuous Source feature not here, and the one the architecture most obviously supports: the tick is fixed, the simulation runs headless, and input arrives as an InputState per tick. Recording those is nearly free and buys replays, ghosts for the time-trial genre, bug reports that reproduce, and a regression harness for the movement solver. The determinism claim in positioning.md is unproven until something replays.
  • No game-state serialization at all. Fixed: saved games are JSON through serde, level changes carry state across, and saves mirror to the store’s cloud. See Saving and level changes.
  • volume is the only sound convar an options screen could set. There is no separate music or effects volume.
  • kerosene-ui is not a UI toolkit. Fixed: the tools’ egui host is now kerosene-toolui, and kerosene-ui is the game UI (section 8).
  • Chisel is a fifth of the codebase in one crate. About twenty-eight thousand lines now that the GPU viewport has landed in it (as gpu/); the renderer, the geometry (brush_edit, shapes, faces) and the egui layer would split cleanly.

2. Already acknowledged

These come straight from the README’s known-limits section and are kept here for completeness.

  • Networking. The simulation runs headless (the hard part is done), but there is no client/server wire protocol, no snapshot, no prediction, and no replication.
  • Skeletal animation. Playback exists: Forge imports skinned glTF with its clips, the renderer skins on the GPU, and prop_dynamic plays, switches and crossfades clips. See section 5 for what is not there.
  • Chisel 3D view. Software-rasterised. It renders on the GPU now, with props drawn as their models and entity helpers (section 19). Still no lighting or shadow preview: the view is flat-shaded.
  • Texture block compression. .ktex is uncompressed; no BCn. The README’s reason – a bad encoder is worse than none – no longer holds: intel_tex_2 and texpresso are pure-Rust BC7 encoders that are good enough.
  • Mipmaps at runtime. Fixed: the whole chain is uploaded.
  • Texture dimensions in the BSP. Fixed: Cleave reads each material’s compiled texture (from the project found next to the map, or --content) and writes its real size, warning by name for any it cannot find. Maps compiled before this carry the old 512 and want a recompile.
  • Audio. Stereo only. Falloff, panning, occlusion, air absorption and a per-room reverb the compiler measures exist; doppler does not.

3. Rendering and visuals

  • Dynamic/real-time lighting and shadows. light_dynamic (point or spot, switchable, movable) and a flashlight are drawn live on top of the bake, with clustered shading for up to 32 lights and shadow maps for the nearest shadow casters. What is left: no dynamic sun or cascaded shadows (the sun is baked whole), no light cookies or IES profiles, no volumetrics, and the 512² shadow maps are fixed-size rather than a convar.
  • Post-processing. The scene is HDR (Rgba16Float) and tone-mapped once per frame (mat_tonemap: ACES by default, Reinhard, or none) with a live mat_exposure. Still no bloom, SSAO, color grading, auto-exposure or motion blur; the HDR target is what each of those needs first.
  • Anti-aliasing. Fixed: r_msaa (4x by default) on the HDR target, resolved before tone-mapping. No TAA or FXAA.
  • PBR material model. Materials are a small closed set (lit/unlit/sky/water/ui). They carry colour, normal, roughness, emissive, occlusion and metalness maps, plus $metalness and $roughnessfactor scalars, shaded with GGX / Smith / Schlick. What is left: no parallax, no per-material shader customization, and diffuse lighting is still direction-free, so normal maps on the world are lit by a fixed notional direction rather than the real one – radiosity normal mapping in Radiance is the fix.
  • Level of detail. No LOD for models or geometry.
  • Decals / projected textures. Mesh decals: cut from the world’s triangles when placed, lit by the lightmap under them and by dynamic lights, from infodecal, the decal command, scripts and the stock weapons (see Game UI). Not on moving brushes or props, and a section streamed in after a decal was placed does not get it.
  • Particles / VFX. None. Muzzle flash, sparks, dust.
  • Reflections. env_cubemap probes, baked by Radiance and reflected by every smooth or metal surface (nearest visible probe, blurred by roughness). No parallax correction, no SSR, no planar reflections.
  • Dynamic sky / weather / time-of-day. Sky is a static skybox; sun and sky lighting are baked.
  • GPU instancing. Fixed: prop_static models draw instanced, one call per model mesh however many copies there are. (They were not drawn at all before, and did not collide; now they do both.)
  • Debug labels. Fixed: labelled passes and pipelines, and debug groups around the world, brush models, props and debug lines.

4. Physics and simulation

  • Rigid-body dynamics. kerosene-physics is the player movement solver plus PhysicsWorld, which wraps Box3D (via box3d-rust) and is wired into the engine: world brushes become static hulls, detail joins them, and moving brush entities (doors, shutters) become static bodies that follow their entity’s pose, so a closed door blocks a thrown prop. prop_physics entities get dynamic box bodies (from their model bounds) whose object properties – mass, friction, elasticity, pickable – decide how heavy, grippy, bouncy and grabbable each one is, and a prop_dynamic_spawner drops them on demand (inheriting those properties). The player is in the simulation too, as a kinematic body, so props bounce off them rather than through them, and walking into one shoves it: how far is a contest between a fixed phys_player_push_force and the prop’s own mass, so a crate slides and a safe does not. The use key is a pick-up tool: aim at a prop and press use to carry it, press use again to set it down, or attack to throw it at phys_launch_speed plus whatever the player was already doing. A carried prop is steered toward the hold point rather than placed at it – driven by impulses under ceilings on speed, acceleration and turn (phys_hold_speed, phys_hold_accel, phys_hold_spin, phys_hold_spin_accel), braking early enough to arrive rather than overshoot – so it stays an ordinary body in the simulation: it turns to face the player as they turn, shoves what it can move, and hangs back short of the hold point when it meets what it cannot, rather than being driven through it. .kmdl models render at their simulated pose, and phys_debug draws the collision boxes. What is not there: convex-hull props (see the callout below – the solver does hulls, props do not use them), joints, per-surface-material friction, a launch-beams gravity gun, turning a prop while carrying it (Source’s use-plus-mouselook), and any sound or effect when a prop hits something.
  • Ragdoll / skeletal physics. None. Box3D has joints, but no skeleton attachment.
  • Vehicles / wheeled physics. None. Box3D has wheel joints, but no vehicle controller.
  • Cloth / soft body / fluid simulation. Water is a volume flag, not simulated. Props fall through water rather than floating.
  • Projectiles / ballistics. No projectile physics. The stock weapons are hitscan: they trace, mark walls and shove props.
  • Generalized physics queries. Mostly fixed: Engine::trace, trace_ray and trace_view sweep a ray or a box against the world, the moving brushes and every prop’s body, and say which entity they hit (TraceHit::entity). Still missing: overlap and radius queries (“what is within 200 units”), and traces against a turned prop’s real box rather than the world box around it.
  • Determinism is asserted, not audited. rayon is on the compile side today, but nothing guards the simulation path against it, or against a HashMap iteration order reaching gameplay. The demo replay in section 1 is the test that would catch it.

5. Animation and characters

  • Skeletal animation playback. Fixed for prop_dynamic: clips play, loop or hold, and crossfade on change. Still missing: a viewmodel – the weapon in front of the camera, the first thing a shooter needs from this – and animation events (a footstep sound on frame 12).
  • Animation state machines (the Animator equivalent). None: two-clip crossfades only.
  • Morph targets / blend shapes. None.
  • Inverse kinematics (foot placement, look-at). None.
  • Animation retargeting. None.
  • Third-person character. Only a first-person controller, which is otherwise solid: walk/run/jump/air-strafe, duck, swim, ladders, noclip, step-up, health/fall-damage/respawn.

6. AI and navigation

  • NPC entities. No npc_* classes; tools/npcclip exists as a material but nothing is an NPC.
  • Pathfinding. NavGraph links walkable faces that share a stretch of edge (T-junctions included, which a compiled floor is full of) and A*-searches it for waypoints. The engine loads each map’s .kwalk with it, and Engine::find_path and Engine::nav are how a game asks. There is still no smoothing into a funnel, no flow fields, and no NPC entity to steer along the result.
  • Behavior trees / state machines / perception. No AI decision-making and no sight or hearing queries.
  • Crowd / group movement. None.
  • Dynamic navigation. The static walkmap cannot reflect a door that is currently open or closed.

7. Audio

  • Occlusion / reverb Fixed: Resonance measures every room at compile time and the mixer has a reverb that reads it; walls muffle, and a sound with no way through is silent. See audio.md.
  • Doppler. A sound moving past you does not bend in pitch.
  • 3D spatialization. No HRTF, no surround.
  • Audio effects / mixing buses. The mixer is voices into a stereo buffer with one reverb bus. No EQ, compression, ducking, or effects graph – and no volume convars for an options screen to drive.
  • Streaming audio. Sounds are decoded whole; no streaming for long ambience or music.
  • Footstep/impact effects. $surfaceprop now parses into a SurfaceProperty, and the engine traces to resolve it and emits stride-timed footstep sounds (footstep/<surface>/n) named off it. Impact effects are still not emitted – neither the player’s own landings nor a physics prop striking a wall makes a sound, though the solver reports every one of those collisions – and no footstep sound files ship, so a game without its own has silent steps (quietly: a game with no footsteps has made a choice).
  • Procedural audio. None.

8. UI and HUD

  • In-game HUD / menus. kerosene-ui: XML layouts, CSS styles and Rhai scripts, with bindings to published game state, hot reload, a stock HUD, damage overlay, pause menu with options, and world panels. See Game UI. Still missing: a main menu before any map is loaded, and dialogue boxes.
  • The attribution screen. The licence exception requires every game to show “Built with Kerosene” when it starts. The engine should draw that itself, so a game meets the condition by default and a fork inherits it; today each game draws it in Game::ui.
  • Gameplay UI toolkit. See above. What is left: scrolling containers, grid layout, and text shaping beyond kerning (no ligatures or right-to-left scripts).
  • Localization. No string tables or translation. Cheap now, painful to retrofit.
  • Runtime text/font rendering for gameplay: a glyph atlas over ab_glyph, with @font-face for a game’s own fonts.

9. Input

  • Gamepad / joystick support. Keyboard and mouse only. gilrs is the pure-Rust answer; Steam Input replaces it on Steam and adds glyphs.
  • Input actions. bind maps keys to commands, and a game’s own held +actions are per-tick state (engine.input.action_held("zoom")); the bindings are on Engine, with keys_for for a rebinding screen to show. There is still no layer that lets a gamepad and a keyboard both drive +attack, and no stock rebinding screen.
  • Bindable keys. Fixed: arrows, the numpad, punctuation, the editing keys, mouse 4 and 5, and the wheel (mwheelup, mwheeldown), under Source’s names.
  • Touch / mobile input. None.

10. Gameplay systems (beyond the FPS sandbox)

  • Weapons and combat. A stub exists (kerosene_game::weapons): three hitscan weapons with ammo, reload, spread and recoil, and a dash on a cooldown. A shot marks walls and shoves props; nothing takes damage, and there are no projectiles, viewmodels or firing sounds.
  • Damage model. Only player fall damage. No damage types, armor, enemy health, or hit reactions, and no OnDamaged output on entities.
  • Inventory / items / pickups. None.
  • Objectives / quests / missions. None.
  • Dialogue system. None.
  • Save/load. Fixed: save, load, F5 and F9, logic_autosave checkpoints. See Saving and level changes.
  • Persistent game flow. Fixed: trigger_changelevel and changelevel carry the player’s health and the game’s own state (through Game::save and Game::load) into the next map, lined up by an info_landmark.
  • Difficulty / game settings. None.
  • Entity classes. The set is enough for the demo level and thin for a real one. Missing from Source’s glue, roughly in the order a mapper hits them: movelinear, tracktrain and path_track, logic_case, logic_compare, math_remap, filter_activator_name and _class, env_fade, env_shake, trigger_gravity, point_viewcontrol, env_sprite, game_ui. None of these are hard; each is a class in kerosene-game and a row in the schema – or in a game’s own crate, since a game registers classes through the Game trait without touching the engine. A game’s class with a model is drawn and collided with when it says how (ClassDef::model(ModelRole::Physics)); until this audit only the three stock prop_* names were.

11. Content and asset pipeline

  • Prefabs / instances. Brushes are authored per map; there is no way to include one .kmap in another. This is not a scene-graph feature – Hammer had instances too – and past a handful of maps the brush workflow does not manage without it.
  • Scene graph. Deliberately a flat entity list (a design choice, listed here for comparison with Unity/Unreal).
  • Material editor. .kmat is hand-written KeyValues; no visual material graph.
  • Shader graph / custom shaders. The shader set is closed.
  • Animation import in Forge. glTF (skins and animations) is in. No FBX, no morph targets, no animated scale, and only a model’s first skin.
  • Model LOD generation. None.
  • Terrain tooling. Brushes only; no heightmap terrain or terrain editor.
  • Level streaming / world partition. Sections: a visgroup marked as streamed is loaded and unloaded around the player by potential visibility (see architecture.md). Still one .kbsp per map, compiled and lit as one; not an open world.
  • Visibility control for the mapper. tools/hint and tools/skip steer Cleave’s splits, but there is no areaportal and no occluder, so a door cannot close off what is behind it.
  • Runtime asset hot-reload. Game UI reloads by itself (ui_hotreload), and scripts and sound tables on command (script_reload, snd_restart). Materials, textures, models and the map do not, nothing watches files for the scripts and sounds, and Chisel-to-engine iteration is F9, which is a full restart.
  • Project templates. Fixed: kerosene-tools new makes a game crate (a Game with a class of its own, its toolset binary, cargo play, cargo tools and cargo ship, a project file and a starter map), or with --content-only a mod. The engine’s base content means either runs before it has any art of its own.
  • Per-target build settings. .kproj names a content tree and a start map, and the window title comes from the game’s LaunchOptions. An icon, default convars and a start map per configuration belong there too, along with named ship targets (Godot’s export presets) for Kiln. kiln --ship --target <triple> builds for another platform, but there are no presets.

12. Networking and multiplayer

  • Client/server protocol (acknowledged). No connection, snapshot, RPC, or entity replication.
  • Prediction and interpolation. None between machines. On one machine the view is interpolated between ticks (section 1).
  • Dedicated-server story. --headless exists, but there is no network stack to serve. If the engine goes Steam-first, Steam Datagram Relay is the pragmatic transport rather than a hand-written one.

13. Platform and distribution

  • Windows/macOS support. CI compiles the workspace on Windows and macOS, but runs the tests and plays a new game only on Linux. Nobody has yet played one there by hand.
  • Mobile / console. None.
  • Crash reporting. A panic hook writes crash.log (section 1); there are no minidumps for native crashes and nothing that phones home. sentry covers both; Breakpad minidumps beside the log are the no-service option.
  • Installer / auto-updater. None.
  • build-content.sh is a shell script. For a game, cargo play and cargo ship are the build, and they run anywhere Cargo does. The script only builds this repository’s base content and the base vault.

14. Integrations

  • Steamworks In, as the opt-in steam feature (see Steam). It covers:

    • init and the relaunch through Steam
    • the overlay, which pauses the game
    • achievements (with progress), stats and leaderboards
    • rich presence, DLC checks and the cloud file API
    • Workshop items mounted as VFS layers, with kerosene-tools workshop upload for putting a .vault up

    All of it is reachable from entity I/O (logic_achievement, logic_stat, logic_leaderboard, logic_richpresence, logic_platform), from Rhai (the platform object, alias steam) and from game code. kiln --ship --steam installs Valve’s redistributable with an rpath and writes the SteamPipe scripts.

    Still missing:

    • Steam Input
    • lobbies and Steam Datagram Relay
    • leaderboard downloads
    • Workshop browsing in-game
    • uploading from Chisel rather than the command line
  • A platform trait kerosene-platform’s Backend: Steam is one implementation and the offline stand-in another, and no game code names either. GOG Galaxy and Epic Online Services are not written.

  • Discord rich presence (discord-rich-presence). Trivial.

  • Crash reporting (section 13).

  • itch.io as a Kiln ship target, via butler.

15. Kerosene-specific tooling gaps (editor and compilers)

Chisel:

  • 3D lighting preview (acknowledged). Baked lighting is not visible until compile and run.
  • VisGroups. Fixed: a VisGroups tab with nested user groups and automatic ones, quick-hide, groups, and object colours.
  • Autosave and recovery. Fixed: arena.kmap~ every minute, offered back when it is newer than the map.
  • Recent files. None.
  • Cordon. Fixed: a box that limits what is shown and what Cleave compiles, sealed by its own walls.
  • Carve. Fixed, along with hollow and a clip tool.
  • Instances / prefabs (section 11) and a prefab library in the browser.
  • Entity report. Fixed: filterable, and it marks outputs aimed at nothing.
  • Check for problems. Leak detection exists, the entity report finds dangling I/O, and “Check for problems” lists every problem in the output panel – broken wires included, by the same check the Outputs tab’s lights make. It does not yet look for a missing texture, and its lines do not lead to what they name.
  • Undo history panel. Fixed: Edit → History....
  • Vertex/edge editing. None. Brushes have it: the select tool’s vertex, edge and face modes, with drag in the flat views, extrude and merge, refusing any edit that would make a brush concave. Meshes still do not: Chisel draws, picks, moves, resizes, duplicates and deletes them, but a mesh is shaped by converting a brush, or by hand in the .kmap. No bevel yet, for either.
  • Texture painting. No brush-based texture painting or blending.
  • Walkmap visualization. The rule-tint view exists, but there is no in-editor preview of the compiled walkmap faces versus what Cleave will actually emit.
  • Multi-entity editing. Fixed: the Object tab and the properties window edit every selected entity, brush or face together, with a key they disagree on marked and left alone until typed over.
  • Play-in-editor. F9 launches the engine. Launching it as a child with a socket and syncing the camera back would be most of what Unity’s play button is worth.
  • Automated level testing. No way to script or assert level logic in-editor beyond Rhai.

The compilers and Kiln:

  • Incremental builds key on mtimes. Kiln now skips every stage’s current outputs (maps also record whether they were built fast or full), but by file time, which breaks on CI and after a git checkout; content hashes do not. A --watch mode and a build cache follow from having them.
  • Independent stages run in sequence. Textures, models and sounds do not depend on each other.
  • No lint. kerosene-tools lint: materials naming textures that do not exist, sounds a map fires that .ksnd does not define, outputs aimed at names no entity has. The editor’s “check for problems” and this should be one function.
  • No map diff. The formats are text, which is the point; a semantic .kmap diff – this brush moved, that output was added – is something no mainstream engine can offer a team using git, and it is a small program.

The engine as a tool:

  • No profiler. Nothing is instrumented. r_speeds reports counts; a frame timeline is what finds the hitch. puffin draws in egui and drops straight into the console overlay; tracy-client is the other answer.
  • stat-style overlays. r_speeds and phys_stats exist; fps, memory, audio voices and physics bodies should be the same kind of thing.
  • Hosted docs. docs.rs builds the published crate’s. Seven of the ten stable crates now refuse an undocumented public item (section 18).

16. Found in the September 2026 audit

A sweep of the runtime and the tools for what the sections above did not already list. Nothing in the tree is marked TODO, FIXME or todo!(); these are gaps found by reading.

Fixed in the same pass:

  • Pausing did not pause. The pause menu, the console and the Steam overlay only showed a layer; the world ran underneath. sv_pause_on_menu (on by default) now stops the ticks, and a pause command does it whatever is open.
  • No focus handling. Out of focus, the game pauses and snd_mute_losefocus silences it; minimised, it stops drawing.
  • The window was always called “Kerosene”. The title and desktop app id are the game’s own, from LaunchOptions.
  • A failed start said nothing. A native error box when the renderer cannot start, and when a windowed game crashes.

The runtime:

  • Console. History is kept, Tab completes maps, saves, sounds, classes and convars, and there are maps, revert and host_writeconfig. config.cfg is still written only on a clean quit unless asked.
  • Debug commands. god, buddha, notarget, give, kill, ent_fire, ent_create, ent_remove, ent_info, setpos, getpos, setang, restart and host_timescale are in; see the console. Still no r_wireframe, host_framerate, or ent_text drawn in the world (ent_info prints to the console).
  • Video. Fullscreen, -w/-h/-fullscreen, a clamped cl_fov, screenshot are in. Still no render scale, texture-quality or anisotropy settings, and no UI text scale.
  • Feel. Ducking snaps the view; no head bob, view roll, landing punch or step smoothing; crouch-jumping does not tuck the feet; no underwater tint, fog or muffling; no drowning, though WaterLevel::Eyes is there to drive it.
  • Rendering. The worldspawn docs describe fog, and nothing implements it. The sky is one equirectangular texture: no cubemap or 3D skybox.
  • Audio. No music system (playlists, crossfades), no env_soundscape, no subtitles or closed captions, no per-sound priority, and animated props make no sound. Doors and buttons do now: noise_move, noise_stop and noise_locked, door/move by default.
  • Movers and entities. A door does not push, stop on or crush a blocking player and has no OnBlocked; a player on a moving platform is not carried; no parentname; no door_rotating, point_template/env_entity_maker, point_teleport, trigger_look/trigger_proximity, game_text or sky_camera.
  • Scripting. No traces, spawning, or entity angles and velocity in the Rhai bindings. Random numbers are in: rand, rand_range, rand_int and pick, seeded per map.
  • A map that fails to load leaves a blank world. Its reason is on the main menu.

The tools:

  • Chisel. Cut, copy and paste work (in the editor, not through the system clipboard); go-to by id or name and nine camera bookmarks (not saved with the map) are in. No texture-lock toggle, though Solid::translate_world_locked exists; no find and replace. The layout, helper mode, shading and fly speed are remembered now (chisel.layout); the grid is not, and there is no Preferences dialog, keymap file, or light theme. Undo keeps a whole copy of the map per step.
  • Validation. Nothing checks that a connection’s input exists on its target’s class. Fixed: every connection has a light, green, yellow or red, with the reason (section 19). There is no sound key type, so sound keys have no picker.
  • The output panel cannot be searched, filtered or copied can now; its lines still do not lead to what they name.
  • Other tools. Timbre, Alchemy and Loupe have no undo. Alchemy reads PNG, JPEG and TGA only: no EXR or HDR for skies. Radiance has no named quality presets. No kerosene-tools doctor: there is one now.
  • The texture stage writes the developer textures into every project’s art/ and materials/, although the engine’s base content already has them, because the compilers read tool and sky materials from the project’s own tree. A new game’s .gitignore leaves them out; the fix is for the compilers to read the base content too.

18. Found in the second September 2026 audit

A second sweep, from the game’s side this time: what a game crate built on kerosene reaches for and does not find. Struck through is what was fixed in the same pass; the rest is open.

The game API:

  • Traces never said what they hit. Engine::trace, trace_ray and trace_view return a TraceHit with the entity it hit – a door, a prop – as well as where and which way the surface faced.
  • Only three class names could have a model. Drawing, animating and colliding a model were keyed to prop_static, prop_physics and prop_dynamic. A class now says so itself: ClassDef::model(ModelRole::Physics).
  • No camera control. view_punch, screen_shake, set_fov_override, set_view_angles, and set_camera with a CameraOverride for a cutscene or a death camera. The stock weapons kick.
  • A game could not read its own held actions. +zoom ran as a command once a frame; engine.input.action_held("zoom") is per-tick state now.
  • The bindings belonged to the window. They are Engine::input, so a rebinding screen can read and change them, and bind works headless.
  • Death was always an instant respawn. Game::player_damaged decides how much a hit takes and Game::player_died can take the death over, leaving the player dead until Engine::respawn_player. Maximum health is the game’s to set.
  • Missing lifecycle hooks. frame (every frame, paused or not), player_spawned, can_save, map_unloading and shutdown.
  • No debug drawing for a game. debug_line, debug_box and debug_point, for a number of seconds, under r_debugdraw. Still no text in the world.
  • No seeded random numbers. Engine::rng, seeded by the map and kept in saves; rand, rand_range, rand_int and pick for scripts. The stock weapons’ spread still has a generator of its own.
  • Spawning took a class name and nothing else. Engine::spawn_entity(class, &[(key, value)]) reads keyvalues the way the map loader does, then runs the spawn handler.
  • The walkmap was never loaded. See section 6.
  • Class handlers are bare fn pointers. They cannot capture settings or reach the game’s state; everything goes through a string-keyed HostRequest.
  • No touch or damage callbacks on a class. ClassDef::on_touch and on_damage, with Engine::damage_entity. Still no use or collision callback.
  • Triggers only notice the player. Spawnflag 8 adds physics props. An NPC, when there is one, is still to come.
  • Error types differ by crate. platform and ui return Result<_, String>, the engine mixes that with anyhow, the rest use typed errors. Unifying them is a breaking change, so it wants doing before 1.0.0.

Rendering:

  • $alphatest, $translucent and $nocull were parsed and ignored. On the world, an alpha-tested surface is cut out, a translucent one is blended back to front after everything solid, and $nocull draws both sides. On models the alpha test works, but a translucent model blends where it is drawn, among the solid ones and unsorted, and a two-sided model is still culled. Umbra and Radiance still treat a translucent brush as solid – it blocks visibility and casts a full shadow – unless it is detail.
  • No sprites, billboards or beams. Particles need them first.
  • No render-to-texture or second camera. Security monitors, mirrors, scopes and sky_camera all wait on it.
  • No model attachments or hitboxes. No muzzle point, no held item, no headshot.
  • No brightness or vsync at runtime. mat_gamma and r_vsync, both in the stock options menu and both applied at once.
  • No loading screen. One is drawn for the frame before a load. The load itself still runs on the main thread, so a very long one still holds the window on that picture.

Audio:

  • Pausing did not pause the sound. A paused game holds every sound in the world where it is; the interface’s clicks play on.
  • No random variants or pitch in .ksnd. One file per sound; footsteps get variety by naming (footstep/<surface>/n).
  • A playing voice cannot be changed. No volume, pitch or fade on a handle, so no crossfade and no door loop that fades out.
  • No device choice or hot-plug. The default output always; a device that goes away needs snd_restart.

Platform and saves:

  • Saves and settings went beside the executable. They go to the player’s own directory (~/.local/share/<game>, %APPDATA%\<game>, ~/Library/Application Support/<game>), and old saves in the content tree are still found. --portable keeps them in the content tree. crash.log is still written beside the executable.
  • No local mods folder or -game flag. Workshop items mount; a folder of loose mods does not.
  • No save migration. A newer save is refused and an older one loads as it is; nothing lets a game upgrade its own Game::save data. No save thumbnails.
  • No save or load menu. The pause menu saves and loads, and the main menu loads.

Entities:

  • Missing classes. breakable, wall_toggle, point_hurt, item_* pickups, game_end, player_speedmod are in, with point_teleport. Still missing: physbox, env_spark, env_explosion, and fog and water controllers.
  • Thin common inputs. Kill, AddOutput and FireUser1 to 4 work on everything; SetParent does not, and Enable/Disable are each class’s own rather than universal.

The tools:

  • cargo xtask bundle --out emptied whatever it was given. It clears only a directory it made.
  • cargo xtask help failed. It exits 0.
  • The published README’s links were broken. The bundle points them at GitHub.
  • scripts/install-desktop.sh stopped on a missing icon, and bump-version.sh’s changelog step needed GNU sed. Both fixed; the bump also moves the changelog’s comparison links.
  • No way to clean. kerosene-tools clean, kiln --clean.
  • Mistakes on the command line were guessed at. An unknown command gets “did you mean”, and Chisel refuses a flag it does not know.
  • new could make a game that depends on itself, and made no repository, CI or test. A game called Kerosene is kerosene-game; new runs git init and writes a CI workflow, .gitattributes, rust-version and a test of the pickup class.
  • Four copies of “is this output up to date”. One, kerosene_vfs::up_to_date.
  • “Check for problems” showed one. It lists them all in the output panel.
  • Chisel and Kiln each build the compilers’ arguments. One list, kerosene_vfs::toolchain::MapStages.
  • No shell completions. The top-level command line is hand-rolled, so clap cannot generate them.
  • The generated game has no licence. Choosing one is the author’s, but new could ask.
  • No --watch. kiln --watch and play --watch; the game reloads a rebuilt map under map_autoreload.

Code health:

  • The stable crates’ public items were half undocumented. math, physics, script, vfs, game, console and platform are documented and warn on anything new that is not. entity (about 160 items), ui (about 280) and engine (about 150) are not yet, so the lint is off for them.
  • Broken links in the crates’ own docs. Every crate’s rustdoc builds with warnings as errors.
  • Big files. engine.rs’s console commands are in their own module now; kerosene-render/src/gpu.rs is still 3,200 lines.

19. The editor overhaul (September 2026)

Chisel was reworked towards Source 2’s Hammer: a GPU 3D view, a layout of toolbar, per-tool options bar, outliner and docked asset browser, hover cards, schema-declared entity helpers, a Hammer-style Outputs/Inputs pair with validation, and vertex/edge/face editing on brushes. See Tools. What doing it turned up:

  • Handles move only in the flat views. A corner can be picked in 3D but not dragged there; the move gizmo still moves whole objects.
  • No marquee for handles. Corners are picked one click at a time (with shift); dragging a box around several is not there yet.
  • No bevel, no edge split, no face split. Extrude and merge are the only operations beyond moving; a face with a new edge across it needs the clip tool.
  • Helpers are drawn, not edited. A spot light’s cone cannot be dragged wider, a sound’s sphere cannot be dragged bigger; the keys are typed.
  • Models are static in the editor. An animated prop_dynamic shows its rest pose, and a model’s skin or body group is not previewed.
  • infodecal and point_worldpanel draw an outline, not the decal’s material or the panel’s layout.
  • The hover card has no material swatch, only the name.
  • The software rasteriser is kept for tests and thumbnails. It no longer draws models or helpers, so a toolset without a GPU callback path shows less than one with it.

17. What the mainstream engines have, and whether it matters

positioning.md argues against chasing Unity, and this document agrees. But some of what they have is small and worth having, and it is worth being explicit about which is which.

Worth borrowing:

  • Project templates and a new command: done, kerosene-tools new.
  • Play-in-editor, in the child-process form above.
  • Input action maps with a rebinding screen.
  • Localization string tables.
  • Build settings per platform, and named export presets.
  • Prefabs, as Hammer instances rather than as a scene graph.
  • A profiler in the editor.
  • Unreal’s stat commands, as overlays.

Deliberately not:

  • A scene graph, a shader graph, a material graph.
  • Heightmap terrain.
  • Open-world streaming. Sections of one map stream; a world of many maps does not.
  • A garbage-collected scripting runtime. Rhai is small on purpose.
  • Character tooling: animation state machines, IK, retargeting, until a game in a genre that needs them exists.

Three gaps worth calling out

  1. The walkmap has a consumer now. The engine loads each map’s .kwalk, NavGraph links its faces (across T-junctions, which it used to miss, leaving a real floor with no links at all) and Engine::find_path answers with waypoints; Engine::debug_line can draw them. What is missing is the NPC that walks them.
  2. The solver already does convex hulls; props do not use them. PhysicsWorld exposes add_dynamic_hull, and world brushes and detail go in as static hulls, so the hull path is real and running. But prop_physics bodies are built from the model’s bounding box (add_dynamic_box_material), so a barrel collides as a crate. Nothing calls add_dynamic_hull. Closing this is wiring a hull out of .kmdl geometry, not new physics.
  3. $surfaceprop is driven at runtime. Traces now report the texinfo they hit, the engine resolves that to a material and its $surfaceprop, and footsteps emit from it. The remaining gap is the other side of the coin – impact effects – and the sound assets themselves, which are content rather than code.

An order

Weighed against positioning.md: the shortest path to a shipped movement shooter or immersive sim, cheapest first.

  1. Interpolation, MSAA, runtime mipmaps, texdata dimensions. Done.
  2. CI. Done: fmt, clippy, the layering check and the tests on Linux, a build on Windows and macOS, and a new game made and played.
  3. Demo record and playback. Proves determinism, becomes the movement solver’s regression fixture, and is the ghost system for genre 3.
  4. A game UI layer and save/load. Done, with a main menu, a loading screen and save and load pages.
  5. Chisel: instances. VisGroups and autosave are in; this is what remains of making the second real map editable.
  6. Weapons and damage. Hitscan, ammo, OnDamaged, decals for the holes.
  7. Steam, Workshop first. Done, as the steam feature.
  8. Impact sounds. The physics sandbox sounding like one; the room it happens in already rings.

Everything after that – animation, NPCs, networking – is in the sections above and is not smaller for being later.

Positioning

What this engine is good at, what it is not, and which games it is shaped to make. A companion to missing-features.md: that sheet says what is absent, this one says which of those absences actually matter.

Game titles here are named for one reason: to point at a shape of game, the same way the README names Valve and id tools to point at a shape of tool. None of their makers are affiliated with, endorse or sponsor Kerosene, and none of their code or content is in it. See NOTICE.

The comparison people reach for

The question that prompted this document was whether Kerosene could compete with Unity. The answer is no, and pursuing it would be the fastest way to kill the project.

Unity is roughly two decades and thousands of engineer-years. Matching it means a shader graph, an animation state machine, terrain, particles, LOD, five platform backends, an asset store, and console certification. That is not a roadmap; it is a company. Kerosene is around 125,000 lines across twenty engine crates and eleven tools.

But “compete with Unity” is the wrong frame, and the architecture already says so. The README’s thesis – the tools are separate from the engine and share open formats – is something Unity structurally cannot do, because its editor is the product and its runtime is downstream of it. Kerosene’s compilers are subcommands. You can script them, run them on a build server, replace one, or write your own. That is a real advantage and a permanent one, and it is worth more than feature parity in a race that cannot be won.

The useful question is not “how do we catch up” but “what is this shaped to be best at”.

A crate, not an editor you live inside

The other thing Unity structurally cannot be is a dependency. A Unity game is a folder the editor owns; the engine arrives as an installer, is upgraded by a hub, and runs your code inside its own. Kerosene inverts that. The game is a Cargo package, kerosene is a line in its Cargo.toml, and cargo play is the loop. The editor and the compilers are a library the game re-hosts as its own binary, so the tools a studio ships to its modders know its classes and launch its game.

That buys things a Rust developer already expects and a game engine rarely gives:

  • The whole stack builds from source, on the developer’s machine, with nothing installed but Rust. There is no binary blob, no account, and no version of the engine that exists only on someone else’s server.
  • Every platform is the same three commands. A game that builds on Linux builds on Windows and macOS, because it is the same code; CI makes and runs a new game on all three on every push.
  • Versions mean something. The engine’s version is the game crate’s API under Semantic Versioning (Versioning), so upgrading is a cargo update that cannot break a game, or a major version with a changelog that says what to change — not a project-upgrade dialog.
  • Nothing is hidden. A game can read the engine’s source in its editor, step into it in a debugger, and patch it with a [patch] section when it must, and it is all one cargo doc away.

The price is the Rust toolchain as a prerequisite, and the first build taking minutes rather than seconds. For the audience this is shaped for — people who would rather have the engine’s source than its installer — that is the right trade.

The Titanfall argument

The strongest case for this architecture is that it has already been made, by someone shipping a game on it.

Respawn built Titanfall on a branch of Source 2009. They did not take it for its renderer, most of which they replaced, or for its tools. They took it for two things: the movement solver and the brush pipeline.

Everything that makes that game feel the way it does – wallrunning, sliding, carried momentum – is built on top of the structure in kerosene-physics: accelerate, air_accelerate, clip_velocity, try_move, step_move. That is the Quake-to-Source lineage, and it is why air-strafing works at all. It is also five readable functions with unit tests, rather than a character controller written for someone else’s game.

The other half is the pipeline. Those levels are still convex solids compiled to a BSP tree with a precomputed PVS, because for a shooter that has to hold sixty frames a second, nothing beats the level telling you what you can see. That is Cleave, Umbra, Resonance and Radiance.

So: Kerosene is already good at the two things that mattered enough to build a studio’s flagship on. What it does not have is everything that was built on top of them with a large team.

Four things it is structurally good at

Movement feel. The solver is legible and tested. Tuning air acceleration is editing a function, not fighting an abstraction.

Entity I/O. kerosene-entity/src/io.rs has connections with delays, parameters and fire-once semantics. A designer wires an entire set-piece without writing code, and the result is a text file that diffs and reviews. This is an underrated design, and the mainstream engines’ answer – callbacks serialised into a scene blob – is worse.

Iteration speed. Brushes compile in seconds. That is a different design loop from sculpting in a DCC package and importing.

Determinism. A headless simulation, a unit scale that lands on powers of two, and no garbage collector. This is more valuable than it sounds; see the time-trial entry below.

Five games it is shaped to make

Each entry names the shape, what it leans on, and what it is still missing.

1. A movement shooter

Titanfall 2, Ultrakill, Neon White, Severed Steel.

These live or die on the solver, and Neon White in particular shows the market does not want photorealism – it wants frame-perfect movement and levels that read at a glance.

Leans on: the movement solver, fast compiles. Needs: weapons, and skeletal animation for viewmodels. Very little else. This is the shortest path from here to a shipped game.

2. An immersive sim or boomer shooter

Half-Life 2, Dishonored, Prey, Deathloop.

All brush-built, all I/O-driven. Physics props plus entity I/O plus Rhai hooks is most of the substrate for “the player solves this room however they like”.

Leans on: entity I/O, the physics sandbox, scripting. Needs: weapons, damage, and eventually NPCs. The best fit for what actually exists today, because it leans on the I/O system and the physics rather than on animation.

3. A time-trial or speedrunning game

Neon White, Clustertruck, the Trackmania design space.

Deterministic movement, fast level compiles, ghost replays. Determinism is the feature here rather than an implementation detail: fixed-timestep physics in the mainstream engines is not replay-stable across machines, and people build elaborate workarounds. Kerosene gets it as a consequence of how it is built.

Leans on: determinism, the solver, iteration speed. Needs: demo recording – which is also how the determinism claim gets proven, since nothing replays a session today – plus save/load for ghosts, and a HUD.

4. Competitive multiplayer with a mod scene

Counter-Strike, Team Fortress 2.

This is where the open-formats thesis pays off hardest, because a community that can write its own tools makes a game outlive its studio.

Leans on: replaceable compilers, open formats, the headless simulation. Needs: the whole of the networking section. Furthest out – but the headless split means the architecture is already pointed the right way.

The mod-scene half does not have to wait for the networking half. A map is a .vault, the VFS already mounts archives as layers, and Steam Workshop is an upload from Chisel and a subscription in-game. That is a weekend against the Steamworks SDK, not a networking stack, and it is the first place the open-formats thesis pays out where a player can see it.

5. Horror

Cry of Fear and the lo-fi space around it.

Baked radiosity in tight brush geometry is an aesthetic, not a compromise, and this genre has no appetite for physically-based shading.

Leans on: Radiance, the brush pipeline. Needs: audio occlusion and reverb – sound doing the work graphics does elsewhere.

The pattern, and what it rules out

Every one of those is geometry-heavy and animation-light. That is not a coincidence: it is exactly where the missing-features sheet is strongest and weakest.

The genres to stay away from are the ones the mainstream engines are actually good at – anything built on characters, dialogue, cutscenes, open worlds, or a large art budget. Not because they are impossible, but because they are a fight against every strength listed above.

What gates all of it

Three items on the sheet stand between “an impressive engine” and “somebody shipped a game with this”:

  1. Skeletal animation. .kmdl stores bones and weights and nothing plays them. No characters means no NPCs and no enemies – a sandbox rather than a game. This is the single biggest blocker on the whole sheet.
  2. Networking. The headless simulation is the hard architectural half, and it is done. Without a wire protocol, though, the comparison to Source stops being true in the way that mattered most to Source.
  3. Windows. CI builds it there, and nothing has run it there. wgpu, winit and cpal all do Windows, so what is left is untested rather than broken – but most of the people who would want this cannot find out until somebody launches it.

One more is smaller than those and gates the feel rather than the possibility: there is no game UI layer, so there is no menu, no options screen and no HUD to put a game behind. It is on the order of days, and it is in the way of every genre above.

Everything else on that sheet – PBR, post-processing, decals, particles, LOD – makes games prettier. These make games possible.

The honest summary

Kerosene will not be a general-purpose Unity competitor. It could plausibly become the engine someone reaches for when they want to build a Half-Life 2-shaped game and find that nothing else lets them. That is a smaller market and a far more winnable one.

Scripting

Entity outputs wired to inputs compose further than they have any right to, and most of a level is built that way. But some things are not a graph. Counting, arithmetic, “pick one of these three at random”, “only if the player still has the crowbar” — expressing those as relays and counters is possible and miserable. This is the layer above.

It is deliberately not a second way to write the engine. A script cannot allocate an entity slot, walk the BSP tree, open a file, or touch the renderer. It reads a snapshot of the world and returns a list of things it would like done.

Running one

Three ways in, all equivalent underneath:

script find_by_name("gate").fire("Open")     # at the console (cheat-protected)
script_execute mymap                          # load scripts/mymap.kscr
script_reload                                 # forget everything and load it again

A map called atrium automatically loads scripts/atrium.kscr when it starts. That is where a level’s script belongs.

From inside a level, a logic_script entity is the seam: an output fires CallScriptFunction and a function in the script runs.

| Key | |
|---|---|
| `scriptfile` | Loaded when the map starts |
| `function`   | What `CallScriptFunction` runs with no parameter |
| `code`       | What `RunScriptCode` runs with no parameter |

Hooks

Define these and the engine calls them.

FunctionWhen
on_map_start()Once, after every entity in the map has spawned
on_tick(dt)Every tick, with the tick length in seconds
on_platform_event(name, data)The store reported something: achievement_unlocked, score_submitted, overlay_opened, … See Steam

A function called through CallScriptFunction may take the caller’s name, or take nothing — both spellings work, so the unused parameter is never forced on you.

fn on_used(who) {
    print(`used by ${who}`);
}

The API

The language is Rhai. Everything below is the whole of it — a scripting surface nobody can read in one sitting is one nobody can audit.

Finding things

find_by_name(name)One entity, or () if there is none
find_all_by_name(name)Every entity with that name — several may share one
find_by_class(class)Every entity of a class
player()The local player, or ()
entity_count()How many entities exist

Asking about something that is not there gives () rather than throwing: checking whether a thing exists is not a mistake.

Entities

e.id e.classname e.targetname e.originRead-only
e.get(key) e.get_float(key) e.has(key)Keyvalues
e.set(key, value)Set a keyvalue; value may be text or a number
e.set_origin(vector)Move it
e.fire(input)Fire one of its inputs
e.kill()Remove it

e.fire on an entity with no targetname addresses that entity and no other. Without that, acting on one of a dozen unnamed lights would mean all of them, or none.

Entity I/O

ent_fire("gate", "Open");
ent_fire("gate", "Open", "");           // with a parameter
ent_fire("counter", "SetValue", "3", 1.5);  // ...and a delay in seconds

Firing goes through the same event queue an output wired in Chisel uses, so the ordering and the delays are the ones the rest of the level plays by.

The console and the world

command(text)Run console text, exactly as typed
cvar(name) cvar_float(name)Read a convar
set_cvar(name, value)Set one — goes through the console, so cheat flags still apply
time() tick() map_name()Where and when you are
print(x) warn(x) error(x)Console output, with severity

The game UI

ui_set(key, value)Publish a value layouts can bind to: ui_set("objective.text", "Find the key")
ui_event(name) ui_event(name, data)An event every layout hears
ui_show(layer, file) ui_hide(layer)Show or hide a layout
place_decal(material, origin, normal, size)Project a decal

See Game UI.

The store: platform (or steam)

Steam when the game is built with it, a stand-in otherwise. The same calls work on both.

.available .name .user .language .overlayRead
.is_unlocked(id) .stat(name) .owns_dlc(appid) .achievements .statsRead
.unlock(id) .progress(id, current, max) .clear(id)Achievements; clear is for testing
.set_stat(name, v) .add_stat(name) .add_stat(name, d) .store_stats()Stats
.submit_score(board, score) .submit_score(board, score, lower_is_better)Leaderboards; the result comes to on_platform_event
.presence(key, value) .clear_presence()Rich presence
.open_overlay(dialog) .open_url(url) .open_store() .open_store(appid) .check_dlc(appid)

Achievement ids and stat names must be declared in the .kproj. The object works inside functions, unlike top-level variables, and so platform and steam cannot be used as variable names. See Steam.

Vectors

let a = Vector(0.0, 0.0, 64.0);
let b = player().origin;
print(`${distance(a, b)}`);

+ - *, .x .y .z, length, distance, dot, normalize, to_string. Distances are kerosene units; see the units section of the README.

What a script cannot do

  • Reach the filesystem. There is no module resolver, so import fails. It would have been a way around every limit below.
  • Run forever. Operations, call depth, string and array sizes are all bounded. A level’s scripts are content, edited by people who make mistakes, and an infinite loop should stop the script rather than the game.
  • Queue unbounded work. A runaway loop calling ent_fire is cut off, the same way the engine refuses to dispatch entity I/O forever.
  • See the world mid-tick. A script reads a snapshot taken when it starts and its effects are applied when it finishes, so a run is a pure function of the world at one instant. That is why the same script run twice on the same world does the same thing.
  • Hold a reference across a death. An entity handle carries its slot’s generation, so a handle kept over a respawn cannot come back pointing at whatever moved into the slot.

Why a snapshot and a queue

The obvious design hands the script a live reference to the entity world. It cannot be done safely — script functions outlive the call that registered them, so the borrow would have to be 'static — and it should not be done anyway. A script that mutates the world halfway through a frame can observe the world in a state no other code ever sees, and that is where the hard-to-reproduce bugs live.

Cheat protection

script, script_execute and script_reload are cheat-protected: they can move entities and set convars, which is not something to hand out for free. A map’s own script loads without sv_cheats, because shipping a level means shipping its script.

An example

The demo level’s, content/scripts/kero_start.kscr in Kerosene Demo, is written to be read: it plays a room tone, puts the objective on the HUD and says when the map has been running two seconds.

Game UI

HUDs, menus, screen overlays and screens in the level are all built the same way: an XML layout, a CSS stylesheet, and optionally a Rhai script, loaded from the content tree like any other asset. It is the shape of Source 2’s Panorama, with one idea borrowed from HTMX: an element says what game state it shows, in its attributes, instead of a script finding it and updating it.

<Label text="{weapon.ammo} / {weapon.reserve}" class:low="{weapon.ammo < 5}"/>
<Panel class="crosshair xh-{weapon.active}"/>

That label shows the ammo count and turns red when it runs low; the crosshair swaps its look whenever the weapon changes. No script is involved. Most of a HUD is built like this.

Edit a layout, stylesheet or script with the game running and it reloads a second later.

Files

ExtensionWhat
.kuiLayout: XML
.kcssStylesheet: a subset of CSS
.kscrBehaviour: Rhai, the same language as level scripts
.ttf .otfFonts, named by @font-face
imagesOrdinary textures: art/ui/logo.png is compiled by Alchemy and named ui/logo

The stock game ships these under content/ui/:

FileShown
ui/hud.kuiWhile a map is running (the ui_hud convar)
ui/overlays/damage.kuiBy the HUD’s script, on the overlay layer
ui/menus/pause.kuiBy Escape (the ui_pausemenu convar)
ui/panels/status.kuiOn a point_worldpanel; the default layout for one

A keypad panel – buttons, a display, a script that checks the code and fires OnUnlock – is in Kerosene Demo, as content/ui/panels/keypad.*, with a test that presses its buttons.

They are written to be read. Copying one is the fastest way to start.

A layout

<root interactive="true" z="100">
    <styles>
        <include src="ui/menus/menu.kcss"/>
    </styles>
    <scripts>
        <include src="ui/menus/pause.kscr"/>
    </scripts>

    <Panel id="menu">
        <Label class="title" text="Paused"/>
        <Button text="Resume" onactivate="resume()"/>
    </Panel>
</root>

The outer element is always <root>. Its attributes:

Attribute
interactive="true"The layout takes the mouse and keyboard while shown, and nothing below it gets them: a menu
zDraw order among layers. Higher is on top
reference-heightHow many UI pixels tall the screen is. Default 1080

<style> and <script> blocks may also be written inline, directly under <root>. You don’t need to escape < and && in bindings and scripts: the loader escapes them before the XML reader sees them.

Elements

Element
PanelA box. Everything is built from these
LabelText: text="...", or the element’s own text
Imagesrc="ui/logo", a compiled texture. Fits contain unless styled otherwise
ButtonClickable and focusable. text= gives it a label
TextEntryA text field. value, placeholder, maxlength, password
Slidermin, max, step, value
ToggleA checkbox. checked, and text= for its label
ProgressBarvalue from 0 to 1
RepeatIts children, count times. Each copy sees index (rename with as=)
Includesrc= another layout, pasted in here

Controls make parts of their own for the stylesheet to style: .slider-track, .slider-fill, .slider-thumb, .toggle-box, .toggle-knob, .progress-fill, and a Label inside a Button or Toggle. Their look before any sheet of yours comes from crates/kerosene-ui/src/default.kcss, which is compiled into the engine.

A <Repeat> has no box of its own. What it makes is laid out in its parent as if written there, so a row of slots made by a Repeat inside #slots { flex-direction: row } is a row. Because of that, select what a Repeat made with a descendant selector (#slots .slot), not >.

Attributes every element takes

Attribute
id, class, styleAs on the web
visible="{...}"Hidden and out of the layout when false
class:NAME="{...}"The class is on while the expression is true
style:PROP="{...}"One property from an expression: style:width="{hp}%". Write style:kero-fill for -kero-fill
on:EVENT="..."Code to run when the event happens
onactivate onchange onsubmit onmouseover onmouseout onfocus onblurCode to run on interaction
cvar="NAME"On a Slider, Toggle or TextEntry: shows the convar and sets it when changed
sound="NAME"A sound to play when activated

Bindings

Anything in braces is a Rhai expression, and published game state is its variables: player.health is the health entry of the player map.

<ProgressBar value="{player.health / 100.0}"/>
<Label text="{if weapon.reloading { 'Reloading' } else { weapon.active }}"/>
<Panel visible="{player.alive && ability.dash.ready}"/>
  • An attribute that is exactly one expression keeps its type, so visible and class: get a real true or false. Anything else becomes text.
  • A binding is evaluated again only when a value it reads changes. A HUD with a hundred bindings costs nothing on a frame where none of their values moved.
  • A value nobody has published yet reads as nothing (()), not as an error. visible="{objective.text}" stays hidden until a script sets an objective.
  • Strings may be written in single quotes, which is the natural way inside a double-quoted attribute.
  • {{ and }} are literal braces.

What is published

Run ui_dump in the console for the live list. The engine publishes:

Key
player.health player.alive player.speed player.x player.y player.z
map.name
ui.menu_open
cvar.NAMEAny convar a layout reads
platform.available platform.name platform.user platform.overlayThe store: Steam, or none
platform.achievements.ID platform.stats.NAME platform.dlc.APPID platform.names.IDEvery declared one; names are the display names. See Steam

The stock game (kerosene::game::Stock) adds its weapons and ability:

Key
weapon.active weapon.slotpistol, shotgun, rifle
weapon.ammo weapon.clip weapon.reserve
weapon.reloading weapon.reload_progress weapon.firingfiring is true for a moment after each shot
weapon.count, weapons.N.name weapons.N.slot weapons.N.activeFor a Repeat over the slots
ability.dash.ready ability.dash.charge ability.dash.remainingcharge runs 0 to 1

A game publishes its own from Rust with engine.ui_set("key", value). A level script uses ui_set("key", value), and a map uses a logic_ui entity’s SetValue input. The HUD doesn’t know or care which one set a value.

Events

Events are one-off: something happened. Listen with on:NAME or a script’s on_event(name, data).

EventDataFrom
player_damagedamountengine
player_diedcauseengine
map_loadedmap nameengine
weapon_changedfrom tostock game
weapon_firedweaponstock game
weapon_empty weapon_reload weapon_reloadedstock game
ability_used ability_readyabilitystock game
achievement_unlocked stat_changed score_submitted overlay_opened …see Steamthe store

Send your own with engine.ui_emit(...), ui_event(...) in a level script, logic_ui’s Emit input, or ui_emit at the console.

Styles

A stylesheet is ordinary CSS: rules, selectors, specificity, comments, @keyframes, @font-face. Selectors can use element names, .class, #id, descendant and > child combinators, and :hover :active :focus :disabled :checked :first-child :last-child.

Layout is flexbox, and a panel’s children stack in a column unless told otherwise.

Lengths are UI pixels. The screen is 1080 of them tall however many real pixels it has, so a HUD laid out at 1080p keeps its shape at 720p and at 4K. % is the parent, as on the web, and vw/vh are the screen.

Properties
Boxwidth height min-* max-* margin padding (and -left etc.) border border-width border-color border-radius. Sizes include padding and border (border-box)
Flexdisplay (flex or none) flex-direction flex-wrap flex flex-grow flex-shrink flex-basis justify-content align-items align-self gap row-gap column-gap
Positionposition (relative, absolute) left top right bottom inset z-index overflow: hidden
Paintbackground-color background: linear-gradient(to bottom, a, b) background-image: url(ui/x) background-size (contain, cover, fill) opacity box-shadow (a soft glow) visibility transform (translate scale rotate)
Textcolor font-family font-size font-weight font text-align vertical-align text-shadow letter-spacing line-height text-transform: uppercase white-space: nowrap
Motiontransition animation with @keyframes
Inputpointer-events: none

Kerosene adds three properties a game HUD needs:

-kero-fill: radial(0.25)Show a clockwise sweep of the panel from twelve o’clock: a cooldown. Also horizontal(x) and vertical(x)
-kero-blend: additiveAdd the panel onto what is behind it: glows, flashes, hit markers
-kero-tint: redMultiply an image by a colour

Transitions and animations can move opacity, colours, transform, -kero-fill, width height and the insets. An animation stays on its last frame when it ends, which is what a flash or a fade-out wants. To play one again, a script calls trigger_class.

font-weight: bold uses a bold face when a stylesheet has loaded one:

@font-face { font-family: "Hud"; src: url("ui/fonts/hud-bold.ttf"); font-weight: bold; }

Without one, the text is drawn slightly heavier. The built-in face is egui’s Ubuntu Light.

A property the stylesheet can’t read is reported once in the console with the file it came from, and the rest of the rule still applies.

Scripts

Bindings show state. A script is for behaviour: a menu page that opens, a keypad that checks a code, a toast that appears and fades. Every layout gets its own sandboxed Rhai VM, with the same limits as a level script, and:

panel(id)A handle to the element with that id
p.add_class(c) p.remove_class(c) p.toggle_class(c) p.set_class(c, on)
p.trigger_class(c)Add a class and restart its animation
p.set_text(t) p.set_attr(name, v) p.set_style(prop, v) p.show() p.hide() p.focus()
store(key) store_or(key, default) set_store(key, value)Published values. set_store publishes, so bindings update
emit(name, data)An event: to every layout, to the game, and from a world panel, to the panel’s outputs
command(text) set_cvar(name, v) play_sound(name)
show_layer(layer, file) hide_layer(layer)
schedule(seconds, "function")Call a function later
time() print warn error
platform (or steam)The store, the same object level scripts have: platform.unlock(id), platform.user, … See Scripting

The engine calls two hooks if a script defines them: on_load() and on_event(name, data). Code in an attribute (onactivate="resume()") runs with target, the element it’s on, and data, the event’s data or the control’s value.

In Rhai a function can’t see the script’s top-level variables. Keep state in the store with set_store, which also lets the layout bind to it. This is how the demo’s ui/panels/keypad.kscr works.

Layers

Each shown layout sits on a named layer. Showing a layer loads its file, or keeps the one already loaded along with its state. Hiding a layer keeps the document, so showing it again is instant.

  • hud is managed by the engine. It shows ui_hud whenever a map is running.
  • menu is Escape’s while a map runs: it toggles ui_pausemenu. With no map running it holds the main menu, ui_mainmenu. While a layer marked interactive is visible, the mouse is released and every key and click goes to it. When the menu closes, the mouse goes back to the game, whether it closed by Escape, a Resume button or a script.
  • loading holds ui_loading for the frame before a map or a save loads.
  • splash holds ui_splash for a moment as a window opens on the main menu.
  • Any other name is yours.

The front end

A game started from its shortcut opens on the main menu, not in a map. The base content’s menus are a starting point; a game replaces any of them by shipping a file at the same path.

FileWhat it is
ui/menus/main.kuiNew Game, Load, Options, Quit. New Game runs newgame, which loads the project’s start map
ui/menus/pause.kuiResume, Save, Load, Options, Restart, Quit to menu (disconnect), Quit
ui/menus/options.kuiThe settings both menus include
ui/menus/saves.kuiThe list of saves both menus include; pressing one calls the including layout’s pick_save(name)
ui/menus/loading.kuiThe loading screen: loading.map is what is loading
ui/menus/splash.kui“Made with Kerosene”; it hides itself after two seconds or a click

What the menus read: game.title; saves.count and, for each save, saves.<i>.name, .map and .when, newest first, published as a menu opens; and error.message, why the last map would not load.

A +map on the command line, -nomenu, a headless run and kerosene-tools play skip the menu and the splash, and go straight to the map. -nosplash skips only the splash. game_end in a map, and disconnect, come back to the main menu.

The developer console always comes first. It opens over a menu, and its keys close it.

World panels

A point_worldpanel entity puts a layout on a surface in the level. The layout is rendered into a texture, only when what it shows has changed, and that texture is drawn in the world, depth-tested and glowing.

Key
layoutThe .kui to show
width heightSize in world units
resolutionTexture height in pixels. The width follows the shape
brightnessHow brightly it glows; 1 is a surface lit to full
interactiveWhether the player can use it
anglesIt faces along its forward direction; place it just in front of a wall

When the player looks at an interactive panel within reach, the crosshair works as the panel’s pointer, and use or attack clicks. The panel takes the press, so the wall behind it isn’t used and the gun doesn’t fire.

A panel’s script talks to the level through emit:

emit("OnUnlock", code);   // fires the panel entity's OnUnlock output

Every event fires the panel’s OnPanelEvent output with the event’s name as the parameter. An event whose name starts with On also fires the output of that name with its data, so the mapper wires OnUnlock in Chisel like any other output. Its Enable, Disable and Emit inputs work the other way.

Set reference-height on the panel layout’s <root> to the entity’s resolution to lay the panel out in its texture’s own pixels.

Decals

Bullet holes, cracks, scorch marks and signs are projected onto the world. When one is placed, the world triangles under it are cut to its square. The decal keeps the lightmap of the surface it lies on, so a decal in shadow is in shadow, and it’s drawn with the world’s own shading, dynamic lights included.

  • infodecal entity: texture (a material) and size. It lands on the surface nearest its origin when the map starts.
  • decal [material] [size] at the console (cheat): wherever you are looking.
  • place_decal(material, origin, normal, size) in a level script, or engine.place_decal(...) in Rust.
  • Every stock weapon’s shots.

A decal material is an ordinary .kmat whose texture has alpha, such as content/materials/decals/bullet.kmat. The newest r_decals decals are kept (default 256) and the oldest are dropped. r_cleardecals removes them all, and a new map starts clean.

Decals land on the static world only, not on doors or props. A decal placed while part of the level is streamed out does not appear on that part.

Console

ui_reloadLoad every layout, style, script and image again
ui_show <layer> <file> ui_hide <layer> ui_toggle <layer> [file]
ui_set <key> <value> ui_emit <event> [data]Publish by hand, to test a HUD without playing
ui_dumpEvery published value, every layer, and what is under the pointer
ui_debug 1Outline every panel
ui_hotreload 0Stop watching the files
ui_hud ui_pausemenuThe HUD and pause menu layouts; empty for none
decal r_decals r_cleardecals

Weapons and the dash

The stock game’s weapons exist so the HUD has something real to show. They are a starting point, not a combat system. Keys: 1 2 3 select the pistol, shotgun or rifle, Q switches to the last weapon, R reloads, mouse1 fires, and mouse2 dashes. The commands behind them are slot1–slot3, lastinv, invnext, invprev, reload and +ability1. Shots trace from the eye and leave bullet holes. Nothing takes damage, because nothing in the engine has health but the player. A game with real weapons replaces kerosene::game::Stock rather than extending it. See crates/kerosene-game/src/weapons.rs.

Using it from a game

The engine owns the UI. A game only publishes state and listens:

#![allow(unused)]
fn main() {
impl Game for MyGame {
    fn tick(&mut self, engine: &mut Engine, _: &InputState, _: f32) {
        engine.ui_set("score.kills", self.kills);
    }
    fn ui_event(&mut self, engine: &mut Engine, name: &str, data: &str, source: &str) {
        if name == "buy" {
            // a shop menu's button: data is the item
        }
    }
}
}

Game::ui, egui, still works and still draws on top. It remains the quickest way to put a debug window on screen.

Threads and memory

The simulation, rendering and the console run on one thread: the one that owns the window. Everything else the engine runs is a module, started and stopped by kerosene::lifecycle.

Start and stop

Modules start in three phases and stop in the opposite order:

  1. Engine: the engine’s own parts (today, the level-streaming workers).
  2. Modules: the ones a game hands over in Game::modules.
  3. Game: the game itself, through Game::setup and Game::shutdown.

Starting is two steps. Every module’s preload runs, in order, before any module’s start, so a module can load what it needs while nothing is running. stop runs in reverse. By the time a module’s stop is called, its threads have been told to stop and have been waited for. A thread that ignores the request is logged and left behind after five seconds, so a stuck worker can never hang the exit.

A module of your own

#![allow(unused)]
fn main() {
use kerosene::lifecycle::{Ctx, Module};

struct Telemetry;

impl Module for Telemetry {
    fn name(&self) -> &'static str { "telemetry" }

    fn start(&mut self, ctx: &mut Ctx) -> kerosene::internals::kerror::Result<()> {
        ctx.spawn("flush", |token| {
            // `sleep` returns true as soon as a stop is asked for.
            while !token.sleep(std::time::Duration::from_secs(10)) {
                // ...send what has piled up...
            }
        })
    }
}
}

Return it from Game::modules. Spawn threads through the Ctx, never std::thread: that is what names them (kerosene-telemetry-flush), counts their memory under the module’s name, and lets the manager stop them. A panic on a managed thread is reported as a fatal kengineThreadError and shuts the engine down cleanly. ctx.pool("name", n) gives a bounded pool of workers; a job that panics is logged and does not take its worker with it.

The streaming pool has at most four workers. KEROSENE_THREADS=n sets the count for everything that asks default_workers().

Memory

TrackingAllocator wraps the system allocator and counts. It is installed by one line in the binary, because a library cannot choose the allocator; new games have it already, and so does the stock kerosene binary:

#![allow(unused)]
fn main() {
#[global_allocator]
static ALLOCATOR: kerosene::lifecycle::TrackingAllocator =
    kerosene::lifecycle::TrackingAllocator::new();
}

The console command mem prints live and peak bytes, how many allocations were made and freed, and a line per tag. A tag is a module’s name for the threads it owns, and misc for the main thread and everything else. mem reset_peak starts the peak over. A free is counted under the tag of the thread that frees, so a buffer handed between threads moves between tags and a tag’s figure can briefly be negative.

Without the line, mem says tracking is off and costs nothing: the system allocator runs untouched. Delete the line from a release build if the few atomic adds per allocation matter to you.

Tool reference

One application, kerosene-tools. Open it with no arguments and you get one window holding every tool, switched from a sidebar down the left edge:

TabKeysWhat it is
Homectrl-1the project, its content, what needs building, recent activity
Assetsctrl-2every file in the content tree, searchable, with its build status
Editorctrl-3Chisel, the world editor
Modelsctrl-4Loupe, the model viewer
Soundctrl-5Timbre, the sound editor
Buildctrl-6Kiln: stages, options, one button, and Cancel
Archivectrl-7Vault: pack, verify, and the archive’s contents as a table

Across the top, a bar says where you are, holds a search box and a Play button, and shows any running build or pack with its elapsed time and a way to stop it. Along the bottom, one output panel (ctrl-`) that every job – a compile, a build, a pack, a play session – logs into. It comes up on its own when a job starts, counts each log’s errors and warnings, and can be filtered by text, narrowed to problems, or copied. None of it is the engine, and none of it depends on it.

ctrl-P (or ctrl-K) opens the command palette: type a few letters of any map, model, sound, tab, job or recent project and press Enter. The match is forgiving – bf finds Build (fast), arena finds maps/dm/arena.kmap – and the matched letters are highlighted.

Home is where the window opens: which project this is and where its content is, the four things people come to do (new map, build, pack, play), counts of maps, materials, textures, models, sounds and scripts – click one to browse them – and the maps themselves, each marked compiled, stale or not compiled.

Assets lists everything under the content folder by category. Sources that compile to something – maps, meshes under models/, sounds under sound/, images under art/ – say whether their compiled form is there and newer than they are. Double-click a map, model or sound to open it in its tool; right-click for show in folder and copy path.

The start page comes up when no project is found, and from Switch project on Home or the project menu at the bottom of the sidebar. It lists recent projects and has three forms: Open a project folder, project file or content folder; start a New game (what kerosene-tools new does); or Make a project of an existing folder (what kerosene-tools init does). Switching away from an unsaved map asks first.

The same stages also run headless, as subcommands, so a script or build server can drive them without a screen: kerosene-tools cleave map.kmap, and so on. Each subcommand is the program it used to be, unchanged in argument and output. A mistyped one is answered with the one it nearly was (unknown command "kilm". Did you mean \kiln`?`).


doctor — is this machine ready?

kerosene-tools doctor

Checks what a first build trips on, and says what to do about each: a Rust older than Kerosene’s minimum, the ALSA headers on Linux, a GPU the renderer can use (and whether it is only a software one), the project here, and which of the toolchain’s pieces can be found. It changes nothing, and exits with an error when something is in the way.

clean — delete what the build wrote

kerosene-tools clean [--content <dir>] [--dry-run]

The same as kiln --clean: deletes every compiled texture, sound and map file under the content tree, and the project’s archive, and nothing else. Models are left, since a .kmdl may have no source to rebuild it from. --dry-run says how much it would delete.


init — starting a project

kerosene-tools init [dir] [--name "My Mod"] [--content content]

Writes a .kproj and creates the content tree beside it: maps, materials, art, textures, models, sound, scripts. Without a --name the directory’s own name is used, and the project file is named after it.

It is safe to run on a directory that is already a project. The project file is the one thing here somebody is expected to have edited, so an existing one is left exactly as it is, and only missing directories are created — which makes this the way to fill in a directory the engine has started using since the tree was made.

A project may name its own directories instead; see formats.md.

Nothing requires this command: the engine and the toolset both create the tree on their own the first time they find a content root. It exists so that starting a project is a thing you do, rather than a thing you discover you should have done.


Chisel — the world editor

A tab in the toolset window, and openable straight to a map:

kerosene-tools                 # the toolset window, on Home
kerosene-tools chisel [map.kmap] [--content <dir>]   # straight to the editor

Finding the content. Chisel needs the content root – the tree holding maps/, materials/ and the .kdef class definitions – to show entity classes and materials at all. The search lives in kerosene-vfs and every tool and the engine share it, so they cannot disagree about which tree is in use.

The reliable way to settle it is a project file: a .kproj at the top of a project naming its content directory. See formats. Without one, the tree is inferred, which works and is why a fresh clone needs no setup – but inference is a guess, and a project file is how you overrule it. Three places are searched, nearest first: the tree the map lives in, the working directory, and the directory the executable is in. In each of them a project file wins over a guess, even a guess found closer down; between them, nearness decides. So a map sitting in a content tree of its own is not claimed by a project on the far side of the disk.

Failing a project file, each place is searched like this: --content if given, then beside the map being opened, then the working directory, then beside its own executable, climbing up to six levels from each looking for a directory holding kerosene.kdef (or, failing that, both maps/ and materials/). Opening a map from anywhere in a project therefore just works, and the map’s own tree wins over the working directory on purpose – editing another project’s map should not show this project’s entities.

The status bar says what it found: 20 classes, 41 materials when the content is there, no entity classes in red when it is not, and n materials unbuilt in amber when a material has no texture behind it. If the first is red, nothing in the editor will look right, and kerosene-tools chisel --help lists the search order. cargo run -p kerosene-chisel --example diagnose prints the same thing without opening a window – discovery, classes, materials, which materials have no texture, and which maps have never been compiled – which is the fastest way to answer “why does Chisel show no entities”. It changes nothing unless given --build.

Textures are built on the way in. Before the editor finishes loading it runs the same texture build Alchemy’s build command does: the developer set is regenerated, then every image under art/ is compiled into materials/. Anything already compiled is skipped, so the cost after the first run is a directory walk. It happens before the materials are scanned, because scanning first and building second is an editor with no textures in it and no way to tell. --no-build turns it off; F9 does it again before compiling the map, so a texture added during a session is compiled before the map that uses it.

Files. ctrl-S saves. A map that has never been saved is asked for a name first rather than being written to untitled.kmap somewhere – the name is what kerosene +map <name> loads, so an editor that picks one for you is an editor whose output you have to go looking for. ctrl-shift-S and file → save as… ask for a name outright. A bare name means a map in this project: typing arena writes <content>/maps/arena.kmap. An absolute path is taken as given.

file → rename… moves the map and the artefacts compiled from it – the .kbsp, .kprt and .kleak. Leaving a .kbsp behind under the old name is worse than clutter: the game still loads it, so a renamed map appears to work under a name that no longer exists and to be missing under the one that does. Renaming onto a map that already exists is refused.

Autosave. A map with unsaved changes is written to arena.kmap~ beside it every minute – never over arena.kmap, so a bad afternoon cannot replace the last good version. Saving removes it. Opening a map whose autosave is newer asks whether to recover it (escape keeps the file and asks next time); a session that died before its first save leaves untitled.kmap~, offered when the editor starts with no map.

file → open lists the maps in the project. Anything that would throw away unsaved changes asks first, and offers to save. The title bar and the status bar both name the file, with a * when there are unsaved changes; a map with no file yet says not saved rather than showing an invented one.

The layout is Hammer 5’s. A strip of tool icons down the left edge – hover one for its name and key. Across the top, two rows: the toolbar, for what applies whatever you are doing (undo, the grid and snap, the cordon, how the 3D view draws, which helpers are shown, how many views, the asset browser, compile), and under it the options bar, which belongs to the tool in hand and changes with it – the select tool’s pick mode and gizmo, the block tool’s material, the shape tool’s shape and sides, the entity tool’s class, the texture tool’s modes, the clip tool’s side. Its right end says, in one line, how to use the tool.

On the right, a dock in two halves, split where you last dragged it. The Outliner above lists every entity in the map by family – lights, players, sounds, logic, props – named ones first, with a search that matches any part of a class or a name; click to select, double-click to fly there. Its VisGroups tab is what is shown (see below). Properties below is whatever is selected; for an entity it has three tabs, Properties, Outputs and Inputs (see Wiring). Along the bottom, when M or the toolbar asks for it, the asset browser is docked. The status bar names the file, the selection’s size, the pointer’s place in the world, the grid, and what content was found.

The layout is remembered between sessions – how many views, whether the asset browser is open, the helper mode, how the 3D view draws, where the panes divide – in chisel.layout in your user data directory, never in the project.

Four panes fill the middle – or two side by side, or one, from the toolbar – each showing whichever view you point it at: 3D, or any of the six flat views – top, bottom, front, back, left and right. Brush geometry is axis-aligned far more often than not and an orthographic view along an axis is the only way to place a vertex exactly without typing numbers. Each pane has a header: its view is a menu there, its zoom or fly speed is written beside it, and the button at the right end (or a double-click on the header, or shift-space) makes it the only pane. Drag the bars between the panes to resize them.

Key
1 2 3 4 5 6select, block, entity, texture, shape, clip tool
Shift+1 .. Shift+4select tool, picking objects, vertices, edges or faces
Mthe asset browser, docked along the bottom
Fframe the selection (everything, with nothing selected)
F1every shortcut, on one sheet
Double-clickobject properties of what was clicked
Alt+Enterobject properties, as a window
Ctrl+Shift+Ethe entity report
Ctrl+X / Ctrl+C / Ctrl+Vcut / copy / paste (a paste lands a grid step over; a cut pastes back in place)
Ctrl+Jgo to an object by id (the brush 12 in Cleave’s warnings) or by part of a name or class
Ctrl+1 .. 9fly a pane to a camera bookmark; Ctrl+Alt+1 .. 9 sets one (kept for the session)
Ctrl+G / Ctrl+Ugroup / ungroup the selection
H / Ctrl+H / Uhide the selection / hide everything else / unhide all
Ctrl+Shift+Ga new visgroup of the selection
Enter (clip tool)cut along the laid line; 6 again cycles what is kept
Ctrl+Shift+C / Ctrl+Shift+Hcarve / hollow
Ctrl+Mtransform: rotate, scale or move by numbers
Rrotate 90 degrees about the axis the pane looks along
Ctrl+L / Ctrl+Iflip horizontally / vertically
Ctrl+Balign the selection to the grid
Shift+Spacemaximise the pane under the pointer, or put the layout back
[ ]finer / coarser grid
Ctrl+Z / Ctrl+Shift+Zundo / redo
Ctrl+Ssave (asks for a name the first time)
Ctrl+Shift+Ssave as
Deletedelete selection
Escapeclear selection, cancel a drag
F9compile (fast) and run
In a 3D pane
W A S Dfly forward, left, back, right
Q Efly straight down and up, whichever way you are looking
Shift / Altfly 2.5x faster / 4x slower
Ctrl+scrollset the fly speed
Right-draglook around
Middle-dragslide the camera sideways and up
In a 2D pane
Middle-dragpan
Scrollzoom about the pointer
Shift-clickadd to or remove from the selection
Drag a gripresize the selection

Keys only reach the pane the pointer is over, and none of them fire while a property field has the keyboard – naming an entity wasd_door should not fly the camera across the level.

Seeing what things are. In the flat views point entities are drawn as what they are: a lamp for a light, a figure for the player start, a speaker for a sound, a diamond for logic, a crate for a prop. Their names are written beside them when you are zoomed in close enough to read them, and always for what is selected. In the 3D view a prop is its model – the one its model key names, or its class’s default – posed by its angles; anything without a model is a small box in its family’s colour. Rest the pointer on anything, in any view, and a card comes up: the class, its name, the first line of its help, the keys it sets, how many outputs and inputs it has and how many of them are broken. On a brush it gives the size and the material of the face under the pointer.

Helpers are what an entity looks like beyond its marker, as the game’s .kdef declares them: a spot light’s cone, pointing where it shines; a light’s two spheres, where it lights a surface fully and where it has all but faded; a sound’s audible radius; a panel’s rectangle; an arrow along the player start’s facing; a line to whatever a target key names. They are drawn for the selection by default – a level with every light’s sphere drawn at once is a level you cannot see – and the bulb on the toolbar cycles between the selection’s, every entity’s, and none. Flat views draw their outlines; the 3D view fills the cones.

The 3D view runs on the GPU, in the same window, so it keeps up with a whole level while you fly. Selected brushes are tinted and outlined, and their outline is drawn faintly through walls as well, so something selected behind a wall can still be found. Tool brushes – triggers, clips – are see-through. (Where there is no GPU, as in the tests, the older software rasteriser draws the pane instead.)

Shapes are drawn rather than loaded from files: an icon set is a set of things to ship, scale, theme and keep in step with the class list, and a dozen lines of geometry is none of those. Classes are matched by prefix, so a game that adds light_dynamic gets the right icon without anything here changing.

The asset browser is docked along the bottom (M, or the pictures icon on the toolbar), with a tab for materials and one for models. Names, folders, a size slider and a search that matches words in any order – so wood crate and crate wood both find props/crate_wood. Materials show what each one does on hover, read from Cleave’s table; models show a rendered preview, because a name is not a shape. Clicking a material makes it the one new brushes wear, and applies it to the selection. Clicking a model arms the entity tool with it: the next click in a view places a prop_static wearing it (or whatever prop class was already chosen), shown in place as a see-through ghost until you click. The options bar says which model is armed, and its x goes back to the class’s own. view → materials in a window... is the same browser floating, for a property field that wants a model.

The old picker was a two-column strip of unlabelled 48-pixel swatches in a 120-point panel, which is a keyhole rather than a browser. Worse, every swatch was drawn from a mip two from the end of the chain — a 2x2 image for a 256-pixel texture — so every material in the list was the same grey smudge and the only way to tell two apart was to hover both. Swatches are built from the smallest mip that is still bigger than the swatch, and a checkerboard looks like a checkerboard.

Resizing. Something selected in a 2D pane wears eight grips: four corners and four edge midpoints. Drag a corner to scale both axes at once, an edge to scale one; the opposite grip holds still, so the selection grows away from where you are pulling rather than wandering across the level. The axis the pane cannot see is left alone. Dragging a grip past the far side stops at one grid square instead of turning the brush inside out — an inverted brush is not a small brush, it is a hole in the world that compiles cleanly. The preview shows the shape it will become and its new size while you drag. The texture stays put in world space rather than stretching, so making a wall twice as wide tiles the bricks twice instead of drawing bricks twice the size.

Key-values on anything. Every object in a map – an entity, a brush, a face – carries key-values, and the Properties panel edits whatever is selected. An entity shows every key its class reads, set or not, with a widget for each (a colour picker for a colour, a dropdown for a choice, checkboxes for flags); a brush or a face shows the keys it carries, and + add a key puts any key at all on any of them. The { } toggle is Hammer’s SmartEdit switched off: every key as plain text, only the keys the object actually has. Selecting several things edits them together: a key they agree on shows its value, a key they disagree on says differs and is left alone on every one of them unless you type over it, which is how one speed lands on six doors at once. With nothing selected the tab shows the world’s own keys (the sky, the level title). Alt+Enter opens the same editor as a window, which is where the add-a-key row and the wiring editor live when there is no room in the tab. A brush’s keys mean nothing to the game – a brush that should do something is tied to an entity – but two of them mean something to the compiler: detail 1 keeps a brush out of the vis tree, section <name> puts it in a streamed section. Under the keys sit the editor’s own notes: a colour to draw the object in, and comments.

Wiring. Outputs are how a map behaves without scripting: a button’s OnPressed fires a door’s Open. An entity’s Outputs tab lists what it fires, one connection a line – output -> target.input, with the parameter, the delay and once when there are any – in the order they will fire. The light at the start of each line says whether it will do anything:

greenthe target exists and takes that input
yellowthe target is !activator, !caller or !player: who that is is decided while the game runs, so only the input is checked
rednothing is called that, or it is but has no such input, or this class never fires that output

Hover the light for the reason. Click a line and a form opens under the list for that connection: the output (with its help), the target (every name in the map, and the four special targets), the input – offered from what the target’s class actually takes, with its help and what its parameter means – the parameter, the delay and only once. + Add starts another connection after the picked one, on the same output and target, which is how a sequence is built; Copy and Paste move a connection between entities. Target names are matched exactly, ignoring case, as the engine matches them: there are no wildcards, and a door* is flagged as the name nobody has.

The Inputs tab is the same list turned round: every connection on any entity that fires at this one. It answers “what opens this door?”, which a list of outputs cannot. Click one to go to the entity it is on. The tab headings count both ways, and say in red how many outputs are broken, so a broken wire is seen without opening the tab. Map → Check for problems lists every broken wire in the map with the rest.

In the views, the selected entity’s wiring is drawn: green arrows to what its outputs fire at, red where the target cannot take the input, blue from whatever fires at it. They are drawn through walls, because a wire does not care what is between.

Brush modelling. The select tool picks whole objects by default. Its other modes, in the options bar or Shift+2..Shift+4, put handles on the selected brushes: squares on the vertices, diamonds on the edges, circles on the faces. Click a handle to pick it, shift-click to add another, and drag one in a flat view to move everything picked; it snaps to the grid, and the brush is drawn as it will be while you drag. A brush has to stay convex, so a drag that would dent one is refused: the preview turns red and says why, and nothing changes. Pull a top corner up for a slope, drag two top corners down onto the bottom for a wedge, push a face out to lengthen a brush. Faces keep their material and texture alignment through all of it. In face mode, extrude grows a new brush off each picked face, as far out as the options bar says. In object mode, merge (also in the Tools menu) makes the selected brushes one, when together they are convex – two halves of a clipped block back into the block. Handles can be picked in the 3D view too, so a corner can be found by looking at it; moving it is done in whichever flat view suits.

Groups and VisGroups. Ctrl+G groups the selection, and from then on a click on any member takes the whole group – unless the select tool’s whole groups option is off, which is how you nudge one thing in a group. Ctrl+U ungroups. VisGroups are named sets you can hide together: select the things that make up a room and press Ctrl+Shift+G, name it, and the VisGroups tab has a checkbox that makes the room vanish from every pane – not drawn, not clickable, not in select-all – and a colour every member is drawn in while it is shown. VisGroups nest (right-click one for new child), and a hidden parent hides its children. Below the user’s groups the tab lists the automatic ones, made from what the map holds: every entity, every world brush, every tool brush, every class that is placed. Untick light and the lights are gone until you tick it back. H hides the selection outright, Ctrl+H hides everything but it, and U brings back whatever was hidden that way. The status bar counts what is hidden, because a hidden brush that is still there is the classic way to compile something you cannot see. Hiding is an edit: Ctrl+Z undoes it.

A visgroup marked with the stack icon is a streamed section: the engine loads and unloads its geometry around the player. See architecture for what that does and does not do.

The cordon. The box icon on the toolbar turns the cordon on: only what is inside the box is shown, and only what is inside is compiled – Cleave seals the box with its own walls, so a corner of a large map compiles and runs on its own in seconds. The arrows icon beside it puts the box’s grips in place of the selection’s so it can be dragged to size in a 2D pane. The box is drawn dashed in red, dimmer when it is off, and it is saved with the map.

Clip, carve and hollow. The clip tool (6) is how a wall gets a doorway or a slab gets a bevel: drag a line across the selection in a 2D pane, and the cut is the plane through that line running along the axis the pane looks down. An arrow on the line shows which side is front; 6 again cycles between keeping both halves, the front or the back, and Enter cuts. The new face wears the material of the face most nearly facing the same way, aligned as that one is. Carve (Ctrl+Shift+C) takes the selected brushes out of every world brush they overlap and then deletes them – a doorway through a wall in one step, at the cost of the wall becoming four brushes. Hollow (Ctrl+Shift+H) turns a brush into walls of a given thickness, mitred at the corners so no two overlap; a negative thickness builds the walls outward around it. A room is a hollowed box. All three keep a brush’s keys and visgroups on every piece.

Meshes. Tools → Convert to mesh turns the selected world brushes into polygon meshes: same shape, same materials and alignment, drawn in their own colour. A mesh is detail – drawn, lit and collided with, but it no longer seals the map or blocks visibility, so converting a wall that seals the map makes it leak and the compile says so. Meshes select, move, resize, duplicate, delete, hide and take a material like brushes; editing their vertices is not in Chisel yet. See .kmap in formats.md.

Transform. Ctrl+M rotates, scales or moves the selection by numbers, about its centre or the world origin; R is a quarter turn about the axis the active pane looks along; Ctrl+L and Ctrl+I flip it; Ctrl+B moves it so its lowest corner sits on the grid. Rotation turns the texture with the brush so a surface keeps its texel, the way moving does.

The entity report (Ctrl+Shift+E) lists every entity in the map: filter by class or name, point or brush, click one to select and frame it in every pane, double-click for its properties. It marks every output aimed at a name no entity has, which is otherwise found by playing the map and wondering why the door did not open. Edit → History... is the undo stack with names on it; click a step to go back to it.

Shapes that are not boxes. A brush is a convex solid and no convex solid is curved, so an archway cannot be one brush. It is several, arranged to read as a curve — which is miserable to do by hand and is why people give up on curves. The shape tool (5) generates them: drag a box in a 2D pane the way you would with the block tool, and it fills it.

Shape
wedgeA ramp: a box with one top edge pulled down to the floor. One brush.
cylinderA pillar or a pipe. One brush however many sides — a convex polygon swept along a line is still convex.
coneA spike or a pyramid. One brush.
archA doorway, a tunnel mouth, a round window: a fan of brushes, one per segment.
stairsSolid steps, one brush each. Solid rather than hollow, because a player falls through thin treads when a physics tick lands between two of them.

The pane you draw in decides which way the shape stands: a cylinder drawn from above is a pillar, the same drag in the front view is a pipe lying across the room. Sides, arc and wall thickness are on the options bar, and only the ones the chosen shape uses are shown. The preview draws the actual shape and the number of brushes it will cost, not the box it is being fitted into. A whole arch is one undo step.

cargo run -p kerosene-chisel --example shape_sheet -- shapes.png renders every shape in both orientations. Geometry has a way of being valid and still wrong; a test can say the brushes are solid, only a picture can say they are an arch.

Brush properties. A brush’s type is a setting on it, at the top of its panel: world geometry, detail, door, trigger_multiple, and so on. Choose one and its settings appear underneath, in the same panel, with nothing to press first. There is no “tie to entity” step — that was a mode change to reach settings that were never anywhere else, and it left detail, which is a wall and has nothing to configure, looking exactly as configurable as a door.

Changing the type is one operation and one undo step, and it keeps the name and the wiring: dropping a targetname would silently break every output pointing at it.

A trigger textures itself. Pick a trigger_* type and its brushes become tools/trigger, so it is invisible and compiles as a region. Forgetting to do that by hand compiles a solid block where a doorway was meant to be, and the map looks broken in a way that has nothing to do with triggers. A door keeps whatever it was textured with, because only a designer knows which door.

The panel also says what the brushes will compile as, read from Cleave’s own material table rather than a copy of it, so the editor and the compiler cannot disagree. It is where tool textures stop being paint: tools/clip says “blocks players only; bullets and sight pass through”. Two rules that surprise everybody are called out on the spot:

  • One tool face changes the whole brush. Solid is the absence of anything more specific, so a single tools/clip face on an otherwise ordinary box stops the box being a wall.
  • A misspelt tool material is not an error. tools/clipp compiles as ordinary world geometry, which is how a doorway gets walled off by a typo nobody sees. The panel says so in red.

Where a door goes. Select a brush entity that moves and the 2D panes draw its travel: an arrow along movedir, an outline where it ends up, and a label saying how far and which way. The distance comes from kerosene_game’s own formula, so the picture and the door agree by construction rather than by luck. Anything with angles gets a facing arrow the same way.

Building a level. Draw brushes with the block tool in a 2D view; they snap to the grid, outward, so a brush is never smaller than the rubber band. Pick a material from the asset browser (M) — picking one with something selected applies it. Place entities with the entity tool; its class is picked on the options bar, grouped by family and searchable. Give brushes a type in the Properties panel to make them a door or a trigger. Wire outputs to inputs on the entity’s Outputs tab.

Wiring, in firing order. A .kmap stores wiring as a flat list of connections. The Outputs tab lists them grouped by output and sorted by delay – the order they will actually fire in – and + Add after a picked line starts the next step of a sequence a tenth of a second later, because two actions at the same instant fire in whatever order the file happens to hold.

Alternatives need an entity that can choose. Every other class fires a list; firing one of two lists depending on something is a decision, and a decision cannot be faked in the editor. That is what logic_branch is for: it remembers a yes or no and fires OnTrue or OnFalse, never both. When one side of a pair is wired and the other is not, the Outputs tab says so, with a button to wire the other — an OnTrue with nothing on OnFalse does nothing half the time, which is a bug you find by playing rather than by reading.

Dragging a selection shows a ghost of it at the destination, in every pane at once including the 3D one, with the offset written out in kerosene units. The rubber band a drag sweeps out is not where anything ends up, so it is not what gets drawn.

Clicking a brush that belongs to an entity selects the entity, not the brush: that is what a designer means by “the door”.

Entity properties. The inspector is driven by the game’s class definitions – the .kdef files Chisel finds under the content root. For a selected entity it lists every key its class reads, whether or not the entity has been given a value for one, with the type, the game’s default and a line of help. Keys are edited with a widget suited to what they hold: a colour picker for a light’s colour, checkboxes for a spawnflag field, a menu of the map’s entity names when wiring an output. A key the definitions do not describe is still shown – that is how a typo becomes visible rather than silent.

Without a .kdef the inspector can only show the keys an entity already carries, which for a freshly placed entity is none. Chisel says so in the status bar rather than looking like a game with no settings.

The 3D pane draws the materials themselves, mipped and filtered, with a selected face tinted rather than painted over so you can still see what it is wearing. view switches between textured, flat colour (each material’s average, when a texture is too busy to read shape through), shaded only (untextured grey, for hunting a brush in the wrong place) and walkmap – which colours each face by its walkmap rule (green to go, red to stay away) without touching the texture, so you can read where NPCs may go without compiling. Lighting is not previewed; compiling and running the map is one keystroke away.

Tool volumes are drawn see-through, as they are in Hammer, because that is what they are: a trigger is a region, not a wall, and one drawn solid hides the room it is sitting in. tools/nodraw and the other solid tool materials are the exception – those are walls, just ones nobody sees, so they stay opaque. A volume does not write depth either, so two overlapping ones both show and neither erases what is behind it.

It reads the compiled .ktex, through the same VFS the engine uses, so what it shows is what the engine will draw – including from inside a .vault archive. The consequence is worth stating plainly: the content has to be built. A material Alchemy has not compiled yet shows as a flat colour derived from its name, so it is a wrong colour rather than a black hole, and view → reload textures picks up a rebuild without restarting.

Materials are picked from a grid of what they actually look like, with a filter box. A list of names is only usable by someone who already knows what every name looks like, which is nobody on their first level.

Face editing. With the texture tool, clicking a face in the 3D pane selects it (shift adds, ctrl picks its material up), and the inspector becomes a face editor: scale, shift, rotation, fit, align to world or to the face itself, justify to an edge or the middle, the lightmap scale, and the face’s walkmap rule – allow, deny, avoid or always – which decides whether the compiler puts that face in the NPC walkmap. Everything acts on the whole selection as one undo step, and a value the selected faces disagree about shows as -- rather than as one of them.

The texture tool has two settings, chosen in the toolbar: what it selects – single face or whole brush (a brush entity is selected whole, as the door) – and when it applies, cycled with T: select only, apply on double-click (the default) or always apply. Shift always just selects, in every combination. The face editor only applies when a face is selected, so it simply has nothing to show for a whole-brush selection.

Compiling. The compile button on the toolbar (or F9) compiles fast and runs. map → compile settings... opens the settings with three buttons: compile runs exactly what the dialog is showing, while fast and full apply a quality preset and leave every other choice alone. That distinction matters – “build even if the map leaks” is not something a quality preset gets to forget. The dialog closes when the compile starts; the log streams into the toolset’s output panel, where it does not cover the map.

A compile starts by running Alchemy over content/art, so a texture added or changed since the last build is compiled before the map that uses it, and the editor’s own texture cache is reloaded when the compile finishes – a new texture shows up in the pane without a restart. Uncheck build materials to skip that stage when the art has not moved.

When a map is not sealed, Cleave writes a .kleak trace beside it and Chisel loads it and draws the route out in red, through every pane. Follow the line to the wall it goes through. map → clear the leak trace puts it away.

Chisel runs the compilers by re-invoking the same executable with the compiler’s name as a subcommand, so they are always present. map → check tools are installed still says which pieces it found, including the engine runtime it launches after a compile.

Developer textures. kerosene-tools alchemy dev-textures writes the standard set, and scripts/build-content.sh runs it: dev/ measurement checkerboards where one cell is 16 ku at the default texture scale, and the full tools/ set – nodraw, clip, playerclip, trigger, hint, skip, skybox and the rest – each a flat colour with its own name written across it. The compiler already understood every one of those; until now none of them had a texture, so picking one in the editor showed nothing.

Units. Distances are kerosene units (ku); one is an inch. A player is 72 ku tall and runs at 320 ku/s, which is the scale a room is judged against. The status bar carries the unit on every number, with metres and a player-height comparison on hover.


Cleave — the BSP compiler

kerosene-tools cleave map.kmap [-o out.kbsp] [--ignore-leaks] [--no-fill] [--dry-run] [-v]
                     [--content <dir>] [--cordon "minx miny minz maxx maxy maxz"] [--no-cordon]

.kmap → .kbsp plus a .kprt portal graph for Umbra, and a .kwalk NPC walkmap built from the world’s flat walkable faces. The walkmap is written every compile – the compiler already has the final face polygons, so the designer does not run a separate step to get one.

Reports every brush and entity problem in one pass rather than stopping at the first, because a designer would rather fix five brushes in one cycle than five.

Leaks. If the flood fill escapes to the void, the map is not sealed and Cleave refuses to build it — visibility would be nearly useless and the compile would take far longer. --ignore-leaks builds it anyway and writes a .kleak trace naming the route out, which is the only practical way to find a one-unit gap in a large map.

A compile that seals the map deletes any .kleak left beside it by an earlier one. A stale trace is worse than none: Chisel loads whatever is on disk, so a map that leaked once would go on reporting a leak through every successful compile after it.

Tool materials

Compile-time intent is expressed with the texture applied to a face, as in Source. Everything under tools/ is a tool material.

MaterialEffect
tools/nodrawSolid, never drawn. The workhorse.
tools/clipBlocks players; invisible; bullets and sight pass through
tools/npcclipBlocks AI only
tools/triggerNot solid, but traces find it
tools/skyboxDraws as sky; where sunlight enters the world
tools/hintForces a BSP split along its plane, then vanishes
tools/skipDoes nothing — what a hint brush’s other faces wear
tools/blocklightCasts a shadow without being solid
tools/grateBlocks movement and bullets; you can see through it
tools/waterWater

An entity’s classname overrides its brushes’ materials: a trigger_multiple is a trigger whatever its faces are textured with, and a detail is detail.

Detail brushes stay out of the world tree. A handrail modelled from thirty brushes would otherwise carve the room into thirty slivers, each of which the visibility compile then has to consider. This is the single biggest lever a designer has over compile time.


Umbra — the visibility compiler

kerosene-tools umbra map.kbsp [--portals map.kprt] [--fast] [--dry-run]

Computes which clusters can see which, and writes the PVS back into the map.

Reports “clusters visible per cluster” — the single best predictor of frame rate in a BSP engine. Lower is better.

--fast stops after the base estimate: much quicker, leaves far too much visible, and exactly what you want while a layout is still moving.

Umbra also writes the potentially audible set — the PVS grown by one room — which is what lets the engine silence a sound that has no way of reaching you.


Resonance — the acoustics compiler

kerosene-tools resonance map.kbsp [--content DIR] [--portals map.kprt]
                  [--fast | --extra] [--rooms] [--dry-run]

Works out what each part of the map sounds like, and writes it back so the engine’s reverb reads it straight off the map. An empty concrete hall rings; a carpeted office does not; a courtyard open to the sky barely does at all — and nobody placed anything to make it so. See audio.md for what the engine does with it.

It listens by throwing rays. From inside every leaf a few hundred rays go out and bounce until they have nothing left, and each surface they strike gives up how much it soaks up per band — from the material’s $surfaceprop, or its $acoustics key when a designer has said otherwise. The averages are what Eyring’s formula wants, measured rather than assumed, in a shape no formula assumes. Leaves that touch and sound alike are then gathered into rooms, one record each, so a hall the BSP cut into twenty pieces is one hall again and a doorway is a boundary because the two sides disagree.

Output
rt60Seconds for each of 125, 500, 2000 and 8000 Hz to fall 60 dB
predelayTime before the first reflection: the round trip to the nearest wall
opennessThe share of rays that escaped to the sky; over 0.35 is outdoors
wetHow loud the room is next to the sound itself
diffusionHow evenly the reflections are spread

--rooms prints the table. --fast uses a third of the rays, which is rougher and several times quicker; --extra four times as many, for a final build. It needs the materials, so it is told where the content is like Cleave is, and it reads the portal file Umbra reads to know which leaves touch — without one it joins leaves whose bounds touch, which is close.

Entity
env_acoustic_overrideThe designer’s last word: rt60, wet, predelay, openness for the room it sits in, and every room within radius. Anything left blank keeps the measured figure.

The whole thing is deterministic — the rays are seeded from the leaf index — so the same map compiles to the same bytes, and a room that rings differently after a rebuild does so because the map changed.


Radiance — the lighting compiler

kerosene-tools radiance map.kbsp [--samples 1-8] [--bounces 0-8] [--scale N]
                  [--ambient-scale N] [--cubemap-size 4-256] [--fast] [--dry-run]

Bakes static lighting from the map’s own light entities, then the reflection probes.

Entity
lightA point light. _light is "r g b brightness".
light_spotA cone. _cone, _inner_cone, _exponent, pitch.
light_environmentThe sun and the sky. _light is the sun, _ambient the fill.
env_cubemapA reflection probe: what the lit world looks like from here.

Brightness reads directly at 100 inches with the default quadratic falloff, so a _light of "255 255 255 200" delivers 200 units of light at normal room distance.

pitch on a light entity is upward-positive, so a lamp shining at the floor is pitch -90.

--samples softens shadow edges at quadratic cost. --bounces 1 is what turns a room lit by a single lamp from a hard pool of light into something that reads as an interior.

A map with no lights compiles fine and is then pitch black, which looks like a broken renderer — so Radiance says so.

Reflection probes. After the lightmaps, Radiance stands at each env_cubemap and records what it sees in every direction – one ray a texel, the colour being the lightmap where the ray landed times the surface’s reflectivity, or the sky’s colour if it escaped. --cubemap-size is the edge of each face, 32 by default, which is what Source ships its cubemaps at; the renderer blurs them further for rough surfaces. Put one at head height in each room. Every smooth or metal face reflects the nearest probe it can see, and a room with none reflects an even glow instead. A probe inside a wall sees nothing and Radiance says which. Unlike Source’s buildcubemaps, this is part of the compile: there is no step to forget.


Alchemy — textures and materials

kerosene-tools alchemy compile art/grid.png -o materials/dev/grid.ktex [--normal] [--clamp] [--ui]
kerosene-tools alchemy material dev/grid --basetexture dev/grid --shader lit
kerosene-tools alchemy batch art -o materials --make-materials
kerosene-tools alchemy new-texture Walls/brick --basecolor b.png --normal b_n.png
kerosene-tools alchemy texture-set content/textures/Walls/brick
kerosene-tools alchemy build content
kerosene-tools alchemy info materials/dev/grid.ktex

Compiles PNG/JPEG/TGA into .ktex, authors .kmat materials, and compiles them to the .kmat_c the engine loads.

new-texture is the deliberate way to add one. It makes a folder under content/textures/, copies the images in under canonical names, and writes the texture.kcfg that documents what the set can say — as against the older route of dropping a PNG under art/ and relying on a filename suffix to be guessed correctly. The folder is a texture set: colour, normals, roughness, emissive and occlusion compiled together, plus the material binding them. See formats.md for the folder layout and the config keys.

texture-set compiles one such folder by hand. It derives the set’s name the same way a full build does — by climbing to the textures/ directory above it — so a set compiled either way ends up called the same thing. Pass --root to say otherwise.

build is the whole texture half of a content build for one project: the developer set is generated into art/, then everything under art/ is compiled into materials/, then every texture set under textures/ is. Sets go last, so a set may deliberately shadow a loose image of the same name: the folder is the more specific statement of the two. A project with no textures/ directory builds as it always did. It is one command because three callers need exactly it – this tool, scripts/build-content.sh, and Chisel on the way to opening its window – and three callers with three ideas of what “build the textures” meant is how the editor came to open with no textures in it while the build script insisted everything was fine. Alchemy is a library as well as a command so the editor can call it rather than shell out to a sibling binary that may not be on the path.

batch and build skip an image whose .ktex is already newer than it, so a build with nothing to do costs a directory walk.

Alpha is dropped when an image does not use it, which saves a quarter of the memory. In batch mode, a file ending _normal or _n is taken to be a normal map — the convention beats a flag, because batch compiles run unattended.


Forge — the model compiler

kerosene-tools forge compile art/crate.obj -o models/props/crate.kmdl
                            [--scale-metres] [--z-up] [--scale N]
                            [--material old=new] [--recompute-normals]
kerosene-tools forge compile art/turret.glb -o models/props/turret.kmdl
                            [--scale N] [--material old=new] [--once clip]
kerosene-tools forge info models/props/crate.kmdl

OBJ or glTF (.gltf, .glb) → .kmdl, splitting by material and welding vertices.

glTF carries what OBJ cannot: a skeleton and its animations. Forge reads every triangle mesh in the default scene, the first skin as the model’s bones (parents first, rest poses from the skin’s inverse bind matrices), and every animation, resampled at 30 fps; bone translation and rotation are kept and animated scale is warned about. glTF is metres, Y-up and faces +Z by definition, so there is no --scale-metres or --z-up to get wrong. Clips loop unless named with --once. Kiln picks up .gltf and .glb under art/ beside .obj. A prop_dynamic plays the result.

Two conversions happen on the way in, and getting either wrong produces a model that is subtly rotated or a hundred times too small. OBJ is Y-up with -Z forward; Kerosene is Z-up with +X forward. And modelling packages usually work in metres — --scale-metres converts.

Winding is taken from the OBJ as-is (the axis remap preserves handedness), so export your faces counter-clockwise from the front: .kmdl stores them that way, and a face wound the other way renders inside out. --recompute-normals rebuilds normals from the winding and ignores any in the source, but it does not repair a source that was wound backwards to begin with.

Welding is by the full corner tuple, not by position: two faces meeting at a hard edge legitimately share a position while needing different normals, and merging them rounds off every corner of the model.


Kiln — building a project

The Build tab in the toolset window – stages as cards with a switch each, fast, rebuild everything, ignore leaks and dry run as options, Build and Cancel, and the log in the output panel – and also a headless stage:

kerosene-tools kiln                              # build everything, from here
kerosene-tools kiln --content path/to/content    # or from there
kerosene-tools kiln --only maps --fast           # just relight, quickly
kerosene-tools kiln --only textures              # after adding art
kerosene-tools kiln --dry-run                    # say what would run
kerosene-tools kiln --tools                      # which pieces can be found
kerosene-tools kiln --force -j 4                 # rebuild everything, on four threads
kerosene-tools kiln --clean                      # delete what the build wrote
kerosene-tools kiln --watch --fast               # build again on every save
kerosene-tools kiln --ship dist                  # build, then assemble a distribution

Runs the whole content pipeline over a project: the texture build, then models through Forge, then every map through Cleave, Umbra, Resonance and Radiance, then the pack into a .vault.

It is a program rather than a shell script for one reason, and it is the reason that matters: a script is not shipped. Install the tools, or copy them somewhere, and the thing that knows how to use them stays behind in a git checkout — so the first thing anyone does with a fresh copy of the toolchain is discover the build step is missing. Kiln is part of the one toolset executable, so it needs no shell.

The compilers are stages of the same toolset and Kiln drives them by re-invoking the executable with the stage’s name as a subcommand, exactly as Chisel does. You can still run any stage by hand or from a build server. Only the texture build is a library call, because Chisel makes the same one and the two must not be able to disagree.

Sources decide what gets built: every .obj under art/ becomes a .kmdl at the matching path under models/, and every .kmap under maps/ becomes a .kbsp. Nothing has a list to keep up to date.

A map is rebuilt when its source is newer than its .kbsp, when it was built fast and this build is not, and when any material or texture changed since: Cleave sizes every face by its texture and Resonance reads every material. The compilers are told the same things Chisel’s F9 tells them – one list, kerosene_vfs::toolchain::MapStages, decides both.

--watch stays running after the build and builds again whenever a source changes, polling the tree twice a second and waiting out a burst of saves. What the build writes is not watched. kerosene-tools play --watch does the same while the game runs, and starts the game with map_autoreload 1, so a rebuilt map is loaded again with the player where they stood.

A map that leaks still compiles, and is reported at the end rather than stopping the build — finding out on the first of forty maps that the run is over is not a service. With --ignore-leaks it is built in full and the leak is a warning. The archive is named after the project and written inside the content tree, which is where the engine looks for it.

scripts/build-content.sh in this repository is a thin wrapper: it builds the toolset from source and regenerates the base room from the code that defines it, then calls Kiln and packs the engine’s base content. None of that belongs in a shipped tool.

--ship <dir> is the stage after the content: it builds the project’s game package if the .kproj names one (or takes the kerosene runtime beside the toolset if not), then assembles a distribution — the binary, the .vault under content/, a .kproj pointing at it, both licence texts and a README.txt carrying the notices the licences require. It copies a named list of files rather than a directory, so no tool ever ends up in a player’s hands, and it refuses an archive that is missing or older than the content tree rather than shipping maps nobody built. What it does and does not do for a release is the subject of Publishing.


Vault — content archives

The Archive tab in the toolset window – pack and verify, with the log in the output panel, whether the archive is older than what it packs, and its contents as a searchable table – and also a headless stage:

kerosene-tools vault pack content -o content/kerosene_content.vault [--ext ktex] [--exclude tmp]
kerosene-tools vault list content/kerosene_content.vault [--long]
kerosene-tools vault verify content/kerosene_content.vault
kerosene-tools vault unpack content/kerosene_content.vault -o extracted

The archive belongs inside the content tree, which is where a shipped game keeps its archives and where the engine looks without being told. Writing it into the tree it packs is safe: .vault is never one of the extensions packed.

Packs a content tree into one archive. Every entry carries a CRC, checked on read and by verify.

Loose files still shadow packed ones when both are mounted, so a developer can drop a file next to a shipped archive without repacking.


kerosene — the runtime

kerosene [+command ...] [--content <dir>] [--vault <file>] [--headless <ticks>]

Arguments beginning with + are console commands, so any convar is settable from the command line with no flag needing to exist for it:

kerosene +map kerosene_room
kerosene +map kerosene_room +sv_gravity 200 +developer 1
kerosene --headless 640 +map kerosene_room
kerosene --content path/to/content --vault extra.vault +map kerosene_room

--headless runs the simulation with no window at all — which is what a dedicated server is, not a testing mode bolted on the side.

With no --content, the engine finds the content tree the way every tool does (see Chisel, above) and says which one it took. With no --vault, every .vault in that tree is mounted, in name order, so a packed install runs without being told about its own archives. Loose files still win over packed ones, which is what makes dropping a file beside a shipped archive work.

A map that will not load says why rather than saying “not found in any search path”. The usual reason is that it has never been compiled — the .kmap is right there and nothing turned it into a .kbsp — so that is what it says, along with the command to run and the list of paths it searched.

The console

` opens it, ` or escape closes it, and toggleconsole does the same from a binding or the command line. Those two keys are read by the host before anything else sees them and are never passed on — a way out that the thing you are trying to leave can capture is not a way out, and handling them any later meant the console’s own text field swallowed them. The same mistake put the backtick that opened it into the prompt, so every command typed afterwards began with a character that made it unknown.

While it is open the console takes the keyboard completely and releases the mouse: a console you cannot type an n into without walking forward is not a console. Tab completes the command word and cycles the candidates, up and down walk history, page up and down scroll without disturbing what you are typing.

It introduces itself the first time it opens, because an empty box with a blinking cursor reads as “this accepts nothing”:

Kerosene console -- 52 commands and convars. `find <text>` searches them,
`help <name>` explains one, `cvarlist` lists the lot. Tab completes, up walks
back, ` or escape closes.

Log lines from the engine appear in it as they happen. Crates that are not ours — the graphics backend, the window library — are held to warnings, so opening the console to read one line does not mean scrolling past a page of Vulkan loader chatter. RUST_LOG lifts that: someone who sets it is debugging the thing they set it for.

Useful convars

Convar
sv_gravity sv_maxspeed sv_acceleratemovement tuning
sv_airaccelerate sv_air_max_wishspeedair control — see below
sv_jump_heightjump height in units; the impulse is derived from it
sv_stepsizetallest step walked up without jumping (18)
cl_fov sensitivity m_yaw m_pitchview and mouse
r_drawworld r_fullbright r_lightmap r_novisrendering toggles (cheat)
r_speedsper-frame culling and draw statistics
sv_stream sv_stream_linger r_stream_debugstreamed sections: on/off, seconds a section lingers, draw their bounds
mat_exposureoverall brightness, applied by the tone-map pass
mat_tonemaptone curve: 0 none (clip), 1 Reinhard, 2 ACES filmic (default)
r_msaamultisample anti-aliasing: 0/1 off, anything higher 4x (default)
r_bumpmap r_specularmaterial debug scales: 0 removes normal maps / reflections, 2 exaggerates (cheat)
r_dynamic r_shadowsdynamic lights (light_dynamic, the flashlight) and their real-time shadows
flashlight / cl_flashlighttoggle the flashlight (bound to F) / whether it is on
volume snd_reverb snd_reverb_presetsound; see audio.md
sv_pause_on_menu / pausestop the world while the pause menu, console or Steam overlay is open, or the window is in the background (default on) / pause whatever is open
snd_mute_losefocussilence the game while its window is in the background (default on)
developerverbosity; 2 also traces entity I/O

sv_air_max_wishspeed is the air-speed cap that makes bunny-hopping and surfing work. It is 30 by default, and it is not a bug: changing it changes the game.


Timbre — the sound compiler

Turns .wav, .flac and .mp3 into .kaud. It is the one tool with no Source counterpart, because Source shipped .wav and paid for it in download size; this pays a compile step instead. It is the sound tab in the toolset window, and also a headless stage:

kerosene-tools timbre                          # the sound tab
kerosene-tools timbre build                    # compile a project's sounds
kerosene-tools timbre compile a.wav --gain 0.8 --mono
kerosene-tools timbre info a.kaud

What it reads

WAV goes through the engine’s own decoder — the one that also reads smpl chunk loop points. FLAC and MP3 go through Symphonia, which is a dependency this project would not accept in the engine and does accept in a build tool: see licensing.md.

MP3 is read because people have MP3s, not because it is a good thing to build from. Compiling one to ADPCM is lossy-to-lossy — the artifacts compound rather than cancelling, and the second encoder spends its bits describing the first one’s mistakes. Timbre says so every time, and says so again if the result comes out larger than the source, which for an already-compressed input it often does.

A file whose extension lies about its contents is named as such rather than reported as corrupt: a .wav that is really an MP3 is a thing that happens to downloaded files, and “not a RIFF/WAVE file” sends you looking for the wrong problem.

FLAC can carry a loop region in LOOPSTART/LOOPLENGTH Vorbis comments, which is what game audio has settled on, and Timbre reads it — otherwise a looping ambience compiled from FLAC loses what the same sound in a WAV would keep.

What it decides

Encoding. ADPCM at a quarter the size, or 16-bit PCM. Per sound, not per project: ADPCM is close to transparent on impacts, speech and machinery, and audible on a quiet room tone with a lot of air in it. It also has an attack transient — the quantiser starts at its smallest step and takes a few milliseconds to reach a loud signal — which softens a sharp onset. PCM16 is the escape hatch for material where either matters.

Gain. Applied before encoding rather than at play time, so a sound recorded too hot is fixed once instead of in every entity that plays it.

Loop points. Read out of the WAV’s smpl chunk if it has one. Before this, looping was all-or-nothing: a room tone with a proper loop region had it thrown away and was repeated end to end, click and all.

Channels. A sound placed in the world has to be mono — there is one pan and a stereo file already carries its own left and right, so positioning it applies a pan to a signal that is not a point. Timbre says so, and can fold it down.

The window

timbre with no arguments opens it, because every one of those decisions is better made by seeing and hearing the result than by reading a number. A gain of 0.8 means nothing on a command line; the same 0.8 with the waveform redrawn under it and the clipped samples marked in red means something at a glance.

  • The waveform is the samples that will actually be written, not the source.
  • Peak and a live level meter, the second following the playhead.
  • Play through the same mixer the engine uses.
  • Gain in decibels, encoding, mono, and the loop region shaded on the wave.

Settings are written to sound/timbre.kcfg and read back by timbre build, so the window and the command line cannot disagree about what a build is — the same discipline that makes the texture build a library call rather than a second implementation.

Where the window comes from

kerosene-toolui is a window with egui in it, and the look every tool shares: winit’s application handler, a wgpu surface, an egui integration and the frame loop that drives them, plus theme (the palette, the spacing and the Phosphor icon font, installed once by run), widgets (tool buttons, tabs, sections, chips, menu items with their shortcuts on the right, a dialog) and output (the panel every job logs into). Implement App, call run. It exists because the window is three hundred lines with nothing to do with any particular tool, and a second copy of them is a second place for a resize bug to live – and because a palette each tool chose for itself would be three palettes.

The window icon is the Kerosene mark from .github/Images/, compiled into every binary that opens a window (kerosene_config::icon) and set on the window. X11, Windows and macOS show it from there. Wayland does not let a window carry its own icon: the compositor shows the icon of the .desktop file whose name matches the window’s app id, which is kerosene. scripts/install-desktop.sh installs that entry and the icon set under ~/.local for the current user; --uninstall takes them out again.

Versioning

Kerosene has one version number, and it is the kerosene crate’s. That crate is what a game depends on, so its API is what a version number has to be honest about. It follows Semantic Versioning:

BumpMeans for a game
Major, 2.0.0Something a game could have used changed or went away. The changelog says what to change.
Minor, 1.3.0Something was added. A game that built before still builds, and plays the same.
Patch, 1.2.1Something was fixed. Nothing was added.

Kerosene is developed as a workspace of crates — the engine’s parts, the tools, the formats — and published as one. cargo xtask bundle folds every workspace crate into kerosene as a module, so crates.io holds exactly one crate, a game adds exactly one line, and there is nothing to mix: every part of a given version was built and tested together.

What the promise covers

Covered, where a breaking change needs a major version:

  • The items at the root of kerosene: launch, LaunchOptions, Game, Engine, EngineConfig, VERSION, and kerosene::prelude.
  • The modules kerosene::engine, entity, math, console, physics, script, ui, platform, vfs, game and, with the tools feature, tools.
  • The third-party crates kerosene re-exports for a game’s own use: egui, glam, rhai, winit, serde_json, anyhow and log. A breaking release of one of them reaches Kerosene only in a major release.
  • The Cargo features audio, steam and tools.
  • What a game’s content relies on: the entity classes and their keys, inputs and outputs; the .kproj keys; the console commands and convars the engine registers; the Rhai functions scripts can call; and the command-line flags of the engine and the tools. A map that loads and plays in 1.2 loads and plays in 1.3.
  • The file formats, as the next section says.
  • The minimum Rust version, rust-version in Cargo.toml. Raising it is a minor change, never a patch, and is always in the changelog.

Not covered, and free to change in a minor release:

  • kerosene::internals: asset, audio, bsp, config, kv, map, material, render and walk, and physics::rigid. They are public because tools and ambitious games need them, and they change as the engine does.
  • Anything #[doc(hidden)], such as Engine::level and Engine::physics.
  • kerosene::__k, where the bundle keeps the workspace’s crates. It is hidden, and everything a game needs from it is re-exported above.
  • Log messages, console output wording and the look of the editor.

How Kerosene keeps it

  • Types that will grow are built to. LaunchOptions, EngineConfig and kerosene_tools::Options are #[non_exhaustive] with constructors and setters, so a new option is not a breaking change. The same goes for enums a game only matches on, such as the error enums, PlatformAction and StatKind.
  • Enums the engine dispatches on are not #[non_exhaustive]. ScriptAction, UiAction, PlatformEvent, WeaponEvent and the two Value enums are matched exhaustively inside the engine, so that a new variant cannot be forgotten there. The price is that adding a variant to one of them is a major change.
  • Game’s methods all have defaults, so a new hook is a minor change.
  • Checked at release. cargo semver-checks compares kerosene’s API with the last release, as a step of releasing. During the 1.0.0 pre-releases it reports; from 1.0.0 it blocks the release.

Formats

Compiled files and saved games have their own version numbers, apart from the crate’s:

FormatMagicVersion
Compiled mapKROS2, and each lump its own
ModelKRMD2
TextureKRTX1
SoundKRAU1
ArchiveKVLT1
Acoustics lumpACST1
Cubemaps lumpKCUB1
Walk dataKRWL1
Map source.kmap1
Saved game.ksav1

A format change is judged by what it does to files people already have:

  • A new version that still reads the old one — a new lump, a new optional key — is a minor change.
  • A new version that cannot read the old one is a major change, even if no Rust signature moved. A game’s players have saved games, and a game’s developers have compiled maps.

Compiled content is rebuilt from source with cargo play or kiln, so a format change is also listed in the changelog with what to rebuild.

Pre-releases

Until 1.0.0, versions are 1.0.0-aN (alpha), then 1.0.0-bN (beta), then 1.0.0-rc.N. A pre-release may break the API from the one before, and the changelog marks each such change Breaking with what to do. Cargo never picks a pre-release for a game on its own: a game opts in by naming one.

Making a release

scripts/bump-version.sh <version> sets every crate’s version and exact pins, dates the changelog’s Unreleased section and updates Cargo.lock. Tags are the bare version, 1.0.0-a2, and pushing one builds the toolset and runtime for each platform and attaches them to a GitHub release. Releasing is the whole checklist.

Releasing

How a version of Kerosene goes out. Versioning says which number it gets; this is the order of things.

Before

  1. Choose the number. Read the changelog’s Unreleased section against what the promise covers. Anything marked Breaking is a major version (or the next pre-release, before 1.0.0). cargo semver-checks -p kerosene --baseline-rev <last tag> says what changed in the API since the last release; it is advice before 1.0.0 and a gate after.
  2. Base content. If anything under content/ that the engine’s base content holds has changed, run scripts/build-content.sh, which repacks crates/kerosene-engine/base/base.vault. cargo test -p kerosene-engine base fails when it is stale.
  3. Green. cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace, and CI green on every job. cargo +<rust-version> check --workspace with the rust-version from Cargo.toml keeps that promise.

The release

scripts/bump-version.sh 1.0.0-a3
git diff                          # Cargo.toml, CHANGELOG.md, Cargo.lock
git commit -am "Kerosene 1.0.0-a3"
git push
cargo xtask publish               # checks, publishes, and offers to tag
git push origin 1.0.0-a3

Pushing the tag runs .github/workflows/release.yml. It builds kerosene-tools and the stock kerosene runtime for Linux, Windows and macOS, and attaches them to a draft GitHub release. The release’s text is the changelog’s section for the version, and a version with a - in it is marked a pre-release. It is a draft because the repository’s releases are immutable: once published, a release takes no more files, so the archives go on first and you publish the draft from the Releases page when they are all there.

So do not make the release by hand before the workflow runs: if one already exists for the tag, the workflow stops and says so. Delete it and re-run the release job.

crates.io

Kerosene is published as one crate, kerosene, with the whole workspace folded into it. Publishing is done by hand, because it cannot be undone, and from the repository root:

cargo xtask publish --dry-run          # everything but the upload
cargo xtask publish

Before it uploads anything, publish checks for the usual mistakes:

FindingWhat happens
This version is already on crates.io, or older than one that isStops. Bump it with scripts/bump-version.sh.
Uncommitted changesWarns, lists them, and asks
Commits not pushed, or no upstream branchWarns and asks
This version is already tagged, at an older commitWarns — the bump was probably forgotten — and asks
CHANGELOG.md has no section for this versionWarns and asks

A dry run shows every finding and carries on to the bundle and cargo publish --dry-run regardless. After a real publish, it offers to tag the commit with the version; pushing the tag (git push origin <version>) runs the release workflow. --yes answers yes to every question, for a script; with no terminal and no --yes, the answer is no.

By hand, the same is:

cargo xtask bundle                     # target/bundle/kerosene
cd target/bundle/kerosene
cargo publish --allow-dirty            # the bundle is generated, so git ignores it

cargo xtask bundle copies every workspace crate into target/bundle/kerosene/src/__k/ as a module, rewrites the paths between them, flattens their features into the facade’s (audio, steam, tools), gathers their dependencies into one manifest, and adds the kerosene and kerosene-tools binaries and the licence texts. Nothing in the workspace is published by itself; every package there says publish = false.

CI’s bundle job does all of that on every push, and runs cargo package, which builds the packaged crate again on its own, as crates.io will, and checks it is under the 10 MB limit. The new-game job makes a game against the bundle on all three platforms and plays it. So the dry run should hold no surprises.

Players and game developers install the toolset from it:

cargo install kerosene --features tools     # kerosene-tools and kerosene

The licence expression is GPL-3.0-or-later WITH AdditionRef-Kerosene-Exception-1.0, in every crate’s Cargo.toml and every source file’s SPDX-License-Identifier line. AdditionRef- is how SPDX (from 2.3) names an addition to a licence that is not on its list, which the Kerosene Exception is. cargo-deny parses it and cargo package carries it as written; deny.toml allows Kerosene’s crates under exactly that expression, never the bare GPL. crates.io accepted it for 1.0.0-a2.

After

  • Check the draft release has three archives, publish it, and check each one runs: kerosene-tools new then cargo play against the released version.
  • Start the next Unreleased section of the changelog as changes land.

Software design document

This is the top-down design of the Kerosene engine: what it is for, how it is put together, why it is put together that way, and where it is going. It is the place to start when you need the whole picture, and the place to record a design decision so it is not lost.

It is deliberately not a second copy of the rest of the book. Each chapter states the design and the reasoning, then links to the page that holds the detail:

If you want…Read
to use the engineMaking games
a reference for one featureDocumentation
the internals of one subsystemDevnotes
Status1.0.0-a4 (alpha)
Last reviewed2026-09-30
OwnerT342
ScopeThe workspace in this repository. The demo game lives in a separate repository (kerosene-demo).

Chapters

  1. Goals and scope
  2. Design principles
  3. Architecture
  4. Runtime
  5. Data and the build pipeline
  6. World and entities
  7. Subsystems
  8. Tools
  9. Public API
  10. Quality
  11. Decision log
  12. Status and roadmap
  13. Open issues

Editing this document

  • One chapter is one file in src/sdd/. Edit it, and keep the chapter list above and in src/SUMMARY.md in step.
  • Change Last reviewed above when you review the document, not on every typo fix.
  • State a design as a rule plus a reason. A rule with no reason cannot be re-examined later.
  • Anything that must stay true of the code belongs in a check (cargo xtask layers, a test), with the chapter pointing at it. A claim only in prose drifts.
  • New decisions go in the decision log; known gaps go in open issues. Remove an issue when it is fixed.
  • Diagrams are mermaid blocks, as in the devnotes.
  • Relative links only: CI runs scripts/check-links.py over the book.

To preview: mdbook serve in the repository root.

Goals and scope

What Kerosene is

Kerosene is a Source-1-style game engine written in Rust: BSP levels built from brushes, baked visibility, lighting and acoustics, entity I/O wiring, and a fixed-rate simulation. It ships as one published crate, kerosene, that a game depends on, plus the toolset (an editor and compilers) a game uses to make its content.

The crate is the product. Its public surface is what the engine’s version number follows; see Public API and Positioning.

Goals

  1. A game is a crate. A game implements the Game trait, registers its own entity classes and calls kerosene::launch. The engine owns no game.
  2. Fast runtime, slow build. Everything expensive (visibility, lighting, acoustics, texture and model compilation) happens in tools. The runtime loads compiled files only.
  3. A complete toolchain. Level editor, compilers, asset tools and a whole-project build ship with the engine, so a game does not need to build its own.
  4. Headless by construction. The simulation runs with no window, GPU or input device, so it can be tested and later hosted as a server.
  5. Stable where it matters. One lockstep version, SemVer over the facade’s stable modules.
  6. One declaration per fact. An entity field is declared once and the map loader, saves, editor and scripts all read that declaration.

Non-goals

  • Not a general-purpose engine. There is no scene graph, no open-world streaming of arbitrary assets, no visual scripting. The shape of a level is a BSP of brushes.
  • No runtime compilation of source content. .kmap source is linked only by tools.
  • No networking yet. There is no client/server protocol, replication or prediction. The headless Engine is the groundwork. See Status and roadmap.
  • No migration tooling in alpha. Breaking changes need no migrate command or shim until 1.0 or until real games depend on the engine. See the decision log.

Users

UserUsesCares about
Game authorthe kerosene crate, the toolsa stable API, content that builds and ships
Level designerChisel, the unified tools appeditor ergonomics, fast iteration
Engine contributorthe workspacelayering, tests, devnotes

Constraints

  • Rust edition 2024, MSRV 1.94, toolchain pinned in rust-toolchain.toml.
  • Linux, Windows and macOS; CI builds all three.
  • GPL-3.0-or-later with the Kerosene exception, so games built on the engine are not forced to be GPL. See Licensing.
  • Units: 1 ku (Kerosene unit) = 1 inch, Z up.

See also

Design principles

Three rules govern the whole engine. Each is enforced, or should be, by something other than good intentions.

1. Nothing points upward

Crates sit in layers and depend only on the layers below. Subsystems (render, physics, audio, entities, scripting, UI) do not depend on each other; when two need to share something, it moves down a layer or the engine passes it across.

Why: it keeps each subsystem testable alone, and stops the engine growing a tangle where changing audio breaks rendering.

Enforced by: cargo xtask layers, which reads the table in xtask/src/layers.rs and runs in CI. A new crate is an error until it is given a layer. See Architecture.

2. Everything expensive happens at build time

Source content is compiled by tools into formats the runtime reads directly. Visibility, lighting and acoustics are baked. The runtime does no CSG, no lightmap solving and no format conversion.

Why: it makes the runtime small and its frame time predictable, and gives a clear line between “the editor” and “the game”.

Enforced by: kerosene-map (the .kmap source format) may only be linked at layer 5 or above. See Data and the build pipeline.

3. The engine owns no game

The engine defines the Game trait and the machinery around it. Doors, triggers, weapons and the rest are a game, and the stock set lives in kerosene-game as one implementation among possible others. kerosene-engine does not depend on it.

Why: a game must be able to replace any of the rules, not work around them.

Enforced by: the dependency graph: kerosene-game is a dev-dependency of the engine for tests only. See Runtime.

Supporting conventions

ConventionRuleReason
Fixed tickSimulation runs at sv_tickrate (default 64 Hz); rendering interpolatesDeterministic gameplay, smooth display
Request-shaped I/OHandlers leave a HostRequest/console request instead of reaching into the engineKeeps entities and console plain data, testable without an Engine
Declare onceFields carry reflection attributes (Key, Help, Label, …)One source for loader, saves, editor, scripts
Facade over dependenciesbevy_ecs, bevy_reflect, wgpu reached through our own cratesThe pinned third-party version can change without a public API change
One versionEvery crate shares a version, pinned with =No skew between crates
Units1 ku = 1 inch, Z upOne convention for tools and runtime

See also

Architecture

Layers

---
config:
  layout: elk
---
flowchart TB
    L7["7 runtime binary<br/>apps/kerosene"]
    L6["6 facade<br/>kerosene"]
    L5["5 tools<br/>chisel, cleave, umbra, resonance, radiance,<br/>alchemy, timbre, forge, kiln, vault, loupe, tools, toolui"]
    L4["4 host and game<br/>engine, game"]
    L3["3 subsystems<br/>render, physics, anim, audio, entity, script, ui"]
    L2["2 data and hardware<br/>resource, asset, bsp, walk, map, scene, rhi, material"]
    L1["1 core services<br/>config, reflect, ecs, vfs, platform"]
    L0["0 core<br/>math, kv, console"]
    L7 --> L6 --> L4 --> L3 --> L2 --> L1 --> L0
    L6 --> L5
    L5 --> L4

A crate may depend on any lower layer. Layer 3 crates may not depend on each other, with one recorded exception (ui → script, listed with its reason in xtask/src/layers.rs as something to revisit). kerosene-map may only be linked from layer 5 up.

The authoritative table is xtask/src/layers.rs; the full per-crate graph and summaries are in the Crate map.

What each layer is for

LayerHoldsNotes
0 coreunits and geometry, KeyValues text, console and loggingNo engine concepts
1 core servicesengine.kcfg, reflection and ECS facades, the file system, the store seamkerosene-vfs finds the content root; kerosene-platform hides Steam
2 data and hardwarecompiled formats, the scene contract, the GPU interfacekerosene-rhi is the only crate that opens a GPU; kerosene-bsp has no GPU dependency
3 subsystemsone capability eachIndependent of one another
4 host and gameEngine, Game, host, stock classesThe only place subsystems meet
5 toolseditor and compilersNever linked by the runtime
6 facadekeroseneThe only published crate
7 runtimekerosene::launch(Stock, …)Nothing else

The four seams

Layering says what may not connect. Four seams say where connection happens.

SeamWherePurpose
GameGame trait, crates/kerosene-engine/src/game.rsA game plugs in rules without the engine knowing it
PlatformEngine (no surface) vs hostSimulation without a window or GPU
Contentkerosene_vfs::root and toolchainOne answer to “where is the content, and which binary runs the next stage”
Formatkerosene-asset, kerosene-map, kerosene-bspWriter and reader share struct definitions, so the format is the agreement

Details and diagrams: Crate map.

Packaging

None of the workspace crates is published on its own. cargo xtask bundle folds them into the single kerosene crate (xtask/src/bundle.rs): each package becomes a module, internal paths are rewritten, tool crates sit behind the tools feature, and the dependencies merge into one manifest. A game therefore depends on one crate and one version.

Third-party foundations

ConcernChoiceReached through
ECSbevy_ecs (exact pin)kerosene-ecs
Reflectionbevy_reflect (exact pin)kerosene-reflect
GPUwgpu 25kerosene-rhi
Windowingwinit 0.30kerosene-engine::host
Tool and in-game debug UIegui 0.32kerosene-toolui, host
Physics (rigid)box3d-rustkerosene-physics::rigid
Scriptingrhaikerosene-script
Game UI layouttaffykerosene-ui
Audio outputcpalkerosene-audio

Versions drift; Cargo.toml at the repository root is authoritative. Why these are wrapped is in the decision log.

See also

Runtime

Two layers, one boundary

  • Engine (crates/kerosene-engine/src/engine.rs) is the entire simulation. It has no window, no GPU and no input device.
  • host (crates/kerosene-engine/src/host.rs) owns the winit event loop, the wgpu surface and egui, and calls Engine::frame once per redraw.

Why: an engine that cannot start without a GPU cannot host a server or run in CI. The boundary is enforced structurally: Engine has no surface field. --headless N runs Engine alone for N ticks.

Start-up

kerosene::launch(game, LaunchOptions) (crates/kerosene-engine/src/launch.rs):

  1. installs the logger and crash handler;
  2. parses arguments (--content, --vault, --headless, +map, +cvar);
  3. finds the content root, project (.kproj) and engine.kcfg;
  4. runs headless, or enters host::run_with.

Engine::with_game then asks the game for its classes, calls setup (after the console exists, before config.cfg/autoexec.cfg/command line run, so a game’s convars can be set from any of them), and executes the startup commands.

If no map is named, the engine loads the built-in fallback map kerosene_room (FALLBACK_MAP in crates/kerosene-engine/src/base.rs), compiled into the binary with a small base content set.

Fixed tick, variable frame

---
config:
  layout: elk
---
flowchart LR
    real["real_dt (cap 0.25 s)"] --> acc["accumulator += real_dt<br/>cap 8 × interval"]
    acc --> loop{"accumulator ≥ interval?"}
    loop -- yes --> tick["Engine::tick"] --> loop
    loop -- no --> draw["draw with alpha =<br/>accumulator / interval"]

Simulation runs at sv_tickrate (default 64 Hz). Rendering runs as fast as it can and interpolates between the last two states. The accumulator cap stops a breakpoint or window drag causing a burst of catch-up ticks. The host takes the latest input for view angles and blends only positions, so the camera responds at frame rate.

One tick

Order matters; it is documented step by step in Runtime and the tick. In outline:

  1. game.pre_tick: change movement parameters before they are read
  2. player movement (kerosene_physics::player_move), footsteps, listener
  3. triggers, then entities.run (events, thinks, removals)
  4. engine and game answer entity requests
  5. game.tick: game rules, after I/O and before props step
  6. rigid-body step, streaming update, map script on_tick

The Game trait

Defined in crates/kerosene-engine/src/game.rs; every hook has a default, so a game implements only what it needs.

GroupHooks
Registrationclasses, schema, setup
Map lifecyclemap_loaded, map_unloading
Per tickpre_tick, tick
Host requestsentity_request, console_request
UIwants_ui, ui, ui_event
Savessave, load, can_save
Eventsplatform_event, player_damaged, player_died, player_spawned, frame, shutdown

Design constraint: the engine takes the game out of itself while a hook runs (Engine::with_game_mut), so a hook cannot trigger another hook on the same game. A game changes map with Engine::request_map, not load_map; a nested call is logged and skipped rather than deadlocking.

Requests

Handlers on entity classes get only &mut EntityWorld, and console commands only the console. When they need the rest of the engine they leave a request. The engine claims the verbs it knows (script, play_sound, phys_wake, …), the game gets the rest, and anything unclaimed is reported. This keeps kerosene-entity and kerosene-console plain data structures.

See also

Data and the build pipeline

Source and compiled formats

Authors edit source formats; tools compile them; the runtime reads only compiled formats.

KindSourceCompiledCompiled by
Map geometry.kmap.kbspcleave
Visibility(from .kbsp).kprt / PVS in .kbspumbra
Lighting(from .kbsp)lightmaps in .kbspradiance
Acoustics(from .kbsp)acoustics in .kbspresonance
Navigation(from map).kwalkmap build
Textures and materialsimages, .kmat, texture.kcfg.ktex, compiled .kmatalchemy
ModelsOBJ, glTF.kmdlforge
Soundaudio files, .ksnd.kaudtimbre
Packaginga content tree.vault archivevault

Always-source, read at run time: .kproj (project), .kdef (entity classes), .kscr (map script), .kui/.kcss (UI), engine.kcfg. Saves are .ksav (JSON). The authoritative list with layouts is Formats.

Pipeline

---
config:
  layout: elk
---
flowchart LR
    kmap[".kmap"] --> cleave --> kbsp[".kbsp"]
    kbsp --> umbra --> kbsp
    kbsp --> radiance --> kbsp
    kbsp --> resonance --> kbsp
    img["images, .kmat"] --> alchemy --> ktex[".ktex / .kmat"]
    mesh["OBJ / glTF"] --> forge --> kmdl[".kmdl"]
    snd["audio"] --> timbre --> kaud[".kaud"]
    kbsp --> vault
    ktex --> vault
    kmdl --> vault
    kaud --> vault
    vault --> archive[".vault"] --> runtime["runtime"]
    kiln(["kiln: runs all of the above, skips what is up to date"])

kiln builds a whole project and decides what is stale. Chisel and Kiln share kerosene_vfs::toolchain::MapStages, so the editor’s “build” and the project build run the same stages in the same order.

Content discovery

kerosene_vfs::root is the single answer to “where is the content tree”. It finds it from a marker file, or by climbing to a .kproj, and returns a Found whose why explains the choice. At run time the Vfs is a stack of mounted directories and .vault archives; archives mount after directories so loose files override packed ones, which is what makes modding and iteration work.

Compiled resource container

kerosene-resource defines the compiled container: a header, typed blocks, a dependency list and the hash of the source. A Resource<T> handle is cheap to copy and resolves through a cache. The dependency list and source hash are what let kiln and hot reload (crates/kerosene-engine/src/hotload.rs) tell what needs rebuilding or reloading.

Rules for adding a format

  1. Put the reader and writer in a crate at layer 2 so both sides compile from the same structs.
  2. Give it a short k… extension and add it to Formats.
  3. Compile at build time; the runtime must not need the source format.
  4. Version it in the header. In alpha there is no migration obligation; see the decision log.

See also

World and entities

Two kinds of world data

Static worldEntities
Held bykerosene-bsp (.kbsp)kerosene-entity (EntityWorld)
Changes at run timeno (sections stream in and out)yes
Used fortraces, visibility, rendering, acoustics, navigationeverything that has behaviour

Brush entities (doors, platforms, triggers) are brushes the map compiler splits out of the static world and attaches to an entity; their poses are dynamic and interpolated per frame.

Entities on bevy_ecs

EntityWorld stores entities as ECS components, with bevy_ecs reached through the kerosene-ecs facade. Entities are addressed by Source-style names and slot handles so that map files, scripts and saves can refer to them.

Declared once

A class’s fields are declared once on a Rust type with reflection attributes from kerosene-reflect:

#![allow(unused)]
fn main() {
#[reflect(@Key("speed"), @Help("Units per second"))]
speed: f32,
}

The map loader (KeyValues → fields), the save snapshot, the Chisel entity inspector and scripts all read that declaration. Other attributes: Label, Hidden, Widget, Networked, Transient. Networked is recorded but no code reads it yet (see Status and roadmap).

Classes

A class is registered with a ClassRegistry. A .kdef file describes it for the tools: keys, defaults, inputs, outputs and helper { … } blocks that tell Chisel how to draw it. The stock classes (kerosene-game) are an ordinary registration, not special.

World geometry types

Brush entity class names carry no prefix (the func_ prefix was dropped in 1.0.0-a4). Among them: door, door_rotating, detail, brush, button, ladder, breakable, wall_toggle, rotating, illusionary, areaportal, occluder, water, liquid, movelinear, platform, tracktrain, physbox. There is no compatibility shim: old maps and .kdef files need the same rename. The list in CHANGELOG.md (1.0.0-a4, Changed) is authoritative.

Entity I/O

Level logic is wired with Source-style connections: output → target.input (parameter, delay, fire-count). The connection encoding lives in kerosene-kv; delivery is an ordered event queue in kerosene-entity. Handlers receive &mut EntityWorld only; anything wider is a HostRequest answered by the engine or the game (see Runtime). This keeps kerosene-entity testable without an engine.

Scripting

Per-map .kscr scripts run in Rhai (kerosene-script). Scripts see a snapshot of the world and return a queue of actions (ScriptAction) the engine applies, so a script cannot reach engine state directly. Entity I/O remains the primary level-logic mechanism; scripts are for what wiring cannot express. Hooks (for example on_tick) are optional.

Saves

crates/kerosene-engine/src/save.rs writes .ksav as JSON: entity snapshots (everything not Transient), player state, the map, and the game’s own data through Game::save/load. Level changes carry state across maps. See Saving and level changes.

See also

Subsystems

Each subsystem is a layer-3 crate (or small group) that depends only downward and never on another subsystem. The engine in layer 4 is where they meet. Each section gives the design decision; the devnote has the internals.

Rendering

Crates: kerosene-rhi, kerosene-scene, kerosene-material, kerosene-render.

  • Split by role. rhi opens the GPU and surface (the only crate that does); scene is the CPU-side description of what to draw (camera, world mesh, lightmaps, lights, probes, decals, UI display list); material holds materials on the GPU; render is the wgpu renderer.
  • Why a scene contract. The UI and tools can produce draw data without a GPU, and tests can build and inspect it without one. A software rasteriser remains for tool tests.
  • World drawing. PVS-culled, with lightmaps baked at build time, dynamic lights and probes layered on top. The world is divided into sections that stream in on worker threads and upload on the main thread.

Detail: Rendering and streaming.

Physics

Crate: kerosene-physics.

  • Player movement is Source-style (gamemovement), deterministic and separate from rigid bodies, using BSP traces through the CollisionWorld trait.
  • Props are rigid bodies in a PhysicsWorld backed by box3d-rust. Static world sections are added as hulls as they stream in.
  • Player and props couple explicitly: the player’s box is synced into the rigid world before the step (props bounce off) and the player’s requested direction pushes props (leaning on a crate works).

Detail: Physics.

Audio and acoustics

Crate: kerosene-audio.

  • A pure-Rust mixer with spatial sound and an FDN reverb; cpal output is behind the audio feature.
  • Acoustics are baked by resonance into the .kbsp; at run time the engine picks reverb parameters from the listener’s position.

Detail: Audio and acoustics, Audio.

Animation

Crate: kerosene-anim. Skeletons, clip sampling, crossfade and a skinning palette. NPC behaviour is not built (see Status and roadmap).

Scripting

Crate: kerosene-script. Rhai, one VM per map, world snapshot in and action queue out. The single rhai dependency lives here; the game UI is the one other user of it (the recorded ui → script exception).

Game UI

Crate: kerosene-ui. XML layout (.kui), CSS-like style (.kcss), Rhai behaviour (.kscr), a data store with bindings, and flexbox layout through taffy. It produces a display list in the scene contract; the renderer draws it. egui is for tools and debug overlays, not for game UI.

Detail: The game UI, Game UI.

Platform (store)

Crate: kerosene-platform. A Platform (validation, batching, events) over a Backend: Steam behind the steam feature, an offline stand-in otherwise. Achievements, stats, cloud saves and Workshop all go through it. Games never call Steam directly.

Detail: The store (Steam), Steam.

Console and configuration

Crates: kerosene-console, kerosene-config. Convars and commands are the engine’s control surface: input binds commands, config files are command scripts, and a game adds its own in setup. engine.kcfg holds persistent engine settings such as the renderer choice.

See also

Tools

Role

The tools are layer 5. They link the engine’s data crates (including kerosene-map, which the runtime never does) and produce the compiled content the runtime loads. The runtime binary (apps/kerosene) links no tool code.

The set

ToolDoesSource-engine analogue
chiselLevel editor: brushes, entities, 3D view, buildHammer
cleaveCSG, BSP, portals, leak detectionvbsp
umbraVisibility (PVS)vvis
radianceLighting bakevrad
resonanceAcoustics bakenone
alchemyTextures and materialsnone
forgeModels from OBJ/glTFnone
timbreSound compile and editnone
vaultPack and inspect .vault archivesnone
loupeInspect compiled filesnone
kilnBuild and ship a whole projectnone
kerosene-toolsOne egui app hosting all of the above, plus headless subcommandsnone

kerosene-toolui provides the shared egui theme and widgets.

Design decisions

  • One window, many tools. kerosene-tools hosts the tools as tabs (Home, Assets, Editor, Models, Sound, Build, Archive) with a command palette and an output panel, so a designer does not juggle executables. Each tool also runs headlessly (kerosene-tools cleave <map>) for scripts and CI.
  • A game can ship its editor. The tools feature of the kerosene crate re-exports the tools so a game can build its own tools binary.
  • The editor mirrors Hammer 5’s layout to be familiar to Source mappers.
  • Chisel renders on the GPU, using the same renderer as the runtime; a software rasteriser is kept for tests.
  • Stages are shared. Chisel’s build and Kiln call the same toolchain::MapStages, so there is one definition of “build a map”.
  • Re-invocation over discovery. A tool launches another stage by re-invoking its own executable (toolchain::command), so a subcommand is always present; the runtime is found next to the executable, then on PATH.

See also

Public API

The boundary

crates/kerosene/src/lib.rs is the game crate and the API boundary. Its public surface is what Kerosene’s version number follows.

PartContentsSemVer
Rootlaunch, LaunchOptions, Game, Engine, EngineConfig, VERSION, preludecovered
Stable modulesengine, entity, math, console, physics, script, ui, platform, vfs, gamecovered
tools (feature)the tool crates, for a game that ships an editorcovered
internalsasset, audio, bsp, config, kv, map, render, walk, …not covered
Re-exported third partiesegui, glam, rhai, winit, serde_json, anyhow, logfollows the upstream

Third-party crates a game names in its own signatures are re-exported so a game cannot link two versions of glam or egui.

The crate is #![warn(missing_docs)], and CI builds its docs with warnings denied.

Versioning policy

  • One lockstep version across all crates; internal dependencies are pinned with = and moved together by scripts/bump-version.sh.
  • Bare-version git tags.
  • Pre-release (1.0.0-aN) versions carry no compatibility promise. Breaking changes are recorded in CHANGELOG.md under Changed and need no migration support until 1.0 or until real games exist.
  • After 1.0, a change to the covered surface follows SemVer.

Full policy: Versioning and Releasing.

Extension points

To…Do
Add rulesimplement Game
Add entity classesregister in Game::classes; describe in Game::schema
Add console commands and convarsregister in Game::setup
Handle custom entity or console verbsGame::entity_request / console_request
Draw HUD or menusGame::wants_ui and Game::ui, or the .kui UI
Persist game stateGame::save / Game::load
React to the storeGame::platform_event
Reuse the stock gamekerosene::game::Stock

See also

Quality

Strategy

The architecture is arranged so most behaviour is testable without a window, GPU or audio device:

  • Engine is headless. Integration tests in crates/kerosene-engine/tests/ (playthrough, gameplay, acoustics, streaming, save, ui, platform, extensions) drive whole maps through ticks and assert on world state.
  • Plain-data layers. kerosene-entity, kerosene-console, kerosene-kv and kerosene-bsp have unit tests with no engine.
  • GPU-free draw tests. scene and the UI produce draw data that tests inspect directly; a software rasteriser covers editor views.
  • Offline platform. kerosene-platform has a stand-in backend, so store code is tested without Steam. The real Steam backend is tested separately.
  • Layering as a test. cargo xtask layers fails on a forbidden dependency.

Baseline at the phase-0 audit: 84 suites, 2,273 tests, 0 failures. Counts move; run the suite for current numbers. Detail: Testing.

Commands

CheckCommand
Buildcargo build --workspace
Testcargo test --workspace --locked
Formatcargo fmt --all --check
Lintcargo clippy --workspace --all-targets --locked -- -D warnings
Layeringcargo xtask layers
Public docsRUSTDOCFLAGS=-D warnings cargo doc -p kerosene --no-deps --features tools --locked
Steam backendcargo test -p kerosene-platform --features steam
Publishable cratecargo xtask bundle
Book linkspython3 scripts/check-links.py
Book buildmdbook build

Continuous integration

.github/workflows/ci.yml runs on pushes to MASTER and on pull requests:

JobChecks
check (Linux)fmt, clippy, layers, tests, Steam tests, docs
platformsbuilds on Windows and macOS
bundlebuilds, packages and plays the published crate
booklink check and mdBook build
denycargo-deny using deny.toml (licences, advisories)

mdbook.yml deploys the book to GitHub Pages; release.yml handles releases.

Known gaps

  • No test drives the real launch boot path end to end.
  • Rendering correctness is checked by capture/smoke tests rather than image comparison against references.
  • No networking tests, because there is no networking.

See also Open issues.

See also

Decision log

Each entry: what was decided, why, and what it costs. Append new decisions at the end; do not rewrite old ones. If a decision is reversed, add a new entry that supersedes it and mark the old one.

Status values: Accepted, Revisit, Superseded.


D1. The kerosene crate is the product

Status: Accepted

One published crate; one lockstep version is its API under SemVer; tags are the bare version. Internal crates are publish = false and folded into it by cargo xtask bundle.

Why: a game author wants one dependency and one version to reason about. Cost: the bundle step rewrites paths, so the published crate’s layout differs from the workspace.

D2. Layered dependencies, enforced

Status: Accepted

Crates depend only downward; subsystems never on each other; checked by cargo xtask layers.

Why: see Principles. Cost: sharing between subsystems means moving code down a layer, which is sometimes awkward. One exception exists (ui → script) and is marked Revisit.

D3. Bake at build time

Status: Accepted

Visibility, lighting and acoustics are tools, not runtime systems.

Why: predictable frame time; a small runtime. Cost: longer content builds, no fully dynamic worlds.

D4. bevy_ecs and bevy_reflect behind facades, exact-pinned

Status: Accepted

Used for entity storage and field reflection, reached only through kerosene-ecs and kerosene-reflect, pinned with =.

Why: a mature ECS and reflection without handing games Bevy’s API surface or churn. Cost: upgrades are deliberate work.

D5. wgpu and egui versions move together

Status: Accepted

wgpu is pinned to the version egui-wgpu links (25 with egui 0.32); other crates reach wgpu through kerosene_rhi::wgpu.

Why: two wgpu versions in one binary do not interoperate. Cost: wgpu upgrades wait for egui.

D6. Rhai for scripting

Status: Accepted

Why: pure Rust, sandboxable, no build-system burden, easy to give a snapshot and collect actions. Cost: slower than a JIT language, a small ecosystem.

D7. Headless Engine, windowed host

Status: Accepted

See Runtime. Why: servers, CI, tests. Cost: every host concern needs a seam rather than a direct call.

D8. Request-shaped handlers

Status: Accepted

Entity handlers and console commands leave requests rather than call into the engine.

Why: keeps those crates plain data, testable alone. Cost: an indirection, and a verb nobody claims is only caught at run time (it is reported, not silent).

D9. Engine owns no game; fallback map only

Status: Accepted

The engine ships a built-in fallback map, kerosene_room, and base content, not a demo game. The demo game (kero_start) lives in the separate kerosene-demo repository as an ordinary crates.io game crate.

Why: proves a game really is a crate, and keeps the engine free of game content. Cost: the demo can lag the engine (see Open issues).

D10. No migration support in alpha

Status: Accepted until 1.0, or until real games exist

Breaking changes (for example removing the func_ prefix from class names, in 1.0.0-a4) ship with no migrate command or compatibility shim; the CHANGELOG.md says what to change.

Why: nothing yet depends on the old shapes, and shims are cost with no user. Cost: old maps and .kdef files need hand edits.

D11. Short file extensions

Status: Accepted

Formats use short k… extensions (.kmap, .kbsp, .kproj, …).

Why: consistency and brevity. Cost: older material (the demo’s README, some docs) may still show long names.

D12. Source-style movement, separate from rigid bodies

Status: Accepted

Why: the movement feel is the point of a Source-style engine, and it must be deterministic and BSP-trace-based. Rigid props use a general solver (box3d-rust) and couple to the player explicitly. Cost: two collision representations to keep consistent.

D13. License with an exception

Status: Accepted

GPL-3.0-or-later with the Kerosene exception. See Licensing.


Template

### Dn. Title

**Status:** Accepted | Revisit | Superseded by Dm

What was decided.

*Why:* …  *Cost:* …

Status and roadmap

Current version: 1.0.0-a4 (2026-09-28). Alpha: the API is not yet promised.

Refactor phases

The architecture refactor (studied from Source 2; see Phase 0 audit) ran in phases.

PhaseWorkState
0Audit against the checklistDone
1Layer the dependency graph; add cargo xtask layers; remove unused edgesDone
2Compiled resource container and Resource<T> handlesDone
3Reflection (kerosene-reflect)Done
4ECS (bevy_ecs behind kerosene-ecs); stock classes portedDone
5Split render and physics (rhi, material, scene, PhysicsWorld; engine/host split)Done, with leftovers

Leftovers

  • PhysicsProps still lives in the engine and owns the entity-to-body mapping.
  • Render texture and model loading do not yet go through Resources.
  • .kmat runtime fallback is incomplete.
  • kiln --force does not reach the texture pass.

Feature status

AreaState
Fixed tick, interpolationDone
BSP, PVS, baked lighting and acoustics, streamingDone
Entity I/O, Rhai scriptingDone
Game UI (.kui/.kcss/.kscr)Done
Saves and level changesDone
Steam (achievements, stats, cloud, Workshop)Done, optional feature
Unified tools app, Chisel on GPUDone
MSAA, CIDone
Demo record and playbackNot started
Chisel instancesNot started
Weapons and damage (hitscan, ammo, decals)Not started
Impact soundsNot started
Animation-driven NPCsNot started
Networking (protocol, replication, prediction)Not started

The authoritative gap list and its suggested order are in Missing features; this table is a summary and may lag it.

Path to 1.0

  • Close the phase 5 leftovers.
  • Decide the stable API surface (the internals boundary) with at least one real game depending on it.
  • Pick the next feature work from Missing features.
  • Bring the demo game up to date with the current formats.
  • Resolve open issues.

Networking is the largest unbuilt piece. The design groundwork is the headless Engine and the Networked field attribute; Steam Datagram Relay is the suggested transport.

See also

Open issues

Inconsistencies and gaps found while writing this document (2026-09-30). They are recorded here, not fixed. Remove an item when it is resolved.

Documentation

#IssueWhere
O1Crate count is stale: the crate map says 23 engine crates and 39 packages; the workspace has 41 members (it omits kerosene-ecs, kerosene-reflect, kerosene-toolui from the count).src/devnotes/crate-map.md
O2The crate map’s layer table does not list kerosene-ecs and kerosene-reflect; xtask/src/layers.rs puts both in layer 1. It also omits layer 7 (the runtime binary).src/devnotes/crate-map.md
O3phase-0-audit.md describes a 35-crate workspace; it is a point-in-time document and should say so.src/refactor/phase-0-audit.md
O4src/refactor/ and src/docs/Devnotes/reworkConfig.md are not linked from SUMMARY.md.src/SUMMARY.md

Code and content

#IssueWhere
O5Stale kero_start references although the engine no longer ships that map (the fallback is kerosene_room): the map convar default, a doc example, several tests and a help string.crates/kerosene-console/src/lib.rs, crates/kerosene-engine/src/engine/config.rs, crates/kerosene-game/src/triggers.rs, tests in kerosene-vfs, kerosene-ui, kerosene-render; also src/docs/scripting.md, src/gamedev/steam.md
O6The MSRV (rust-version = 1.94) differs from the pinned toolchain (1.98.1). Intentional, but untested: CI does not build on 1.94.Cargo.toml, rust-toolchain.toml
O7PhysicsProps and texture/model loading still bypass the Phase 5 design.see Status and roadmap
O8ui → script is a recorded layering exception waiting for a better home for the shared Rhai code.xtask/src/layers.rs

Demo repository

#Issue
O9kerosene-demo depends on kerosene = "1.0.0-a2" and uses old extensions (.keromap, .keroproj, .keroscript, .kerosnd); a4 uses .kmap, .kproj, .kscr, .ksnd.

Testing

#Issue
O10No test drives the real launch boot path.

Architecture notes

This is the developer-facing companion to architecture.md. That page is the tour: how a map gets from a .kmap to the screen. These notes are the machinery underneath it — the data structures, the call order, the invariants, and the places where the code makes a choice that the tour only gestures at.

They are written to be read next to the source. Every section names the file it is describing, and the diagrams are the shape of the code rather than an idealised version of it. When a note and the code disagree, the code wins; fix the note.

How to read these notes

---
config:
  layout: elk
---
flowchart LR
    welcome["Start here:<br/>README.md"] --> crate["Crate map<br/>dependency direction, seams"]
    crate --> runtime["Runtime & tick<br/>Engine, Game, host"]
    runtime --> map["Map pipeline<br/>cleave → umbra → resonance → radiance"]
    map --> bsp["BSP internals<br/>lumps, queries, traces"]
    bsp --> entities["Entities & scripting<br/>I/O graph, host requests"]
    runtime --> render["Rendering & streaming<br/>PVS, atlases, sections"]
    runtime --> physics["Physics<br/>gamemovement + box3d"]
    runtime --> audio["Audio & acoustics<br/>mixer, FDN reverb"]
    crate --> tools["Tools & build<br/>toolset, kiln, vault"]
    bsp --> tools
    entities --> tools

    classDef start fill:#2962FF,color:#fff
    classDef core fill:#AA00FF,color:#fff
    classDef build fill:#FF6D00,color:#fff
    class welcome start
    class runtime,render,physics,audio core
    class map,bsp,tools build

The ten pages split along the same lines the crates do, which is not an accident: the layering is the architecture, and a page per layer is the cheapest way to keep this document honest.

NoteWhat it coversPrimary sources
Crate mapWho depends on whom, and the four seamsCargo.toml, crates/kerosene/src/lib.rs
Runtime and the tickEngine, Game, host, the 64 Hz loopcrates/kerosene-engine/src/{engine,game,host}.rs
The map pipelineCleave, Umbra, Resonance, Radiancetools/{cleave,umbra,resonance,radiance}/src/
BSP internals and tracesThe lump format, tree queries, brush tracescrates/kerosene-bsp/src/
Entities and scriptingEntityWorld, the event queue, Rhaicrates/kerosene-entity/src/, crates/kerosene-script/src/
Rendering and streamingMesh build, lightmap atlas, wgpu, PVScrates/kerosene-render/src/
PhysicsSource movement and rigid propscrates/kerosene-physics/src/
Audio and acousticsMixer, reverb, Resonance data pathcrates/kerosene-audio/src/
Tools and the buildThe unified toolset, Kiln, Vaulttools/kerosene-tools/src/, tools/kiln/src/
TestingWhat is tested without a GPU, and whyevery crate’s tests modules

The three rules the whole tree keeps

  1. Nothing points upward. kerosene-math knows nothing. The engine knows everything except the game. The tools sit off to the side. If a dependency would point up, the thing being depended on is in the wrong crate. See Crate map.

  2. Everything expensive happens once, at build time. Visibility, lighting and acoustics are minutes on a developer’s machine and microseconds at runtime. That division is why the tools are not part of the engine, and it is worth more than any feature. See The map pipeline.

  3. The engine owns no game. kerosene-engine defines a Game trait and runs whatever implements it. The stock game lives in kerosene-game and the engine’s own tests depend on it only as a dev-dependency. A game shipped to a player is the runtime plus an archive, with no tool in it. See Runtime and the tick.

Conventions used here

  • File paths are repository-relative (crates/kerosene-engine/src/engine.rs).
  • A Type::method reference means that method exists; the surrounding prose says what invariant it maintains.
  • Diagrams use Mermaid with layout: elk (see book.toml). Rectangles are data or code, rounded shapes are processes, and colours group a subsystem.
  • `sv_*` and `r_*` names are console variables, registered in register_cvars/register_commands in crates/kerosene-engine/src/engine.rs.

Crate map

The workspace is 23 engine crates, 11 tool crates, the toolset that joins them, the kerosene game crate, one application and xtask: 39 packages, all one version. Cargo.toml at the root lists them; the interesting part is the direction of the arrows.

The dependency graph

The crates sit in layers, and a crate depends only on the layers below it. cargo xtask layers checks that on every CI run, from the table in xtask/src/layers.rs; the reasoning is the refactor design document’s, in src/refactor/.

LayerCratesMay depend on
0 coremath, kv, consoleeach other
1 core servicesconfig, vfs, platformlayer 0; each other
2 data and hardwareresource, asset, bsp, walk, map, scene, rhi, materiallayers 0–1; each other
3 subsystemsrender, physics, anim, audio, entity, script, uilayers 0–2, not each other
4 lifecyclelifecyclelayers 0–3
5 host and gameengine, gamelayers 0–4
6 toolstoolui and every toollayers 0–5; each other
7 facadekeroseneeverything
8 runtimekerosene-runtimeeverything

Two extra rules. kerosene-map, the .kmap source format, is for the tools only: nothing below layer 5 may link it, because the runtime loads compiled maps. And the one subsystem edge that is allowed anyway, ui → script, is listed in xtask/src/layers.rs with its reason, as an exception to revisit.

---
config:
  layout: elk
---
flowchart TB
    subgraph L0["0 core"]
        math["kerosene-math<br/>units, planes, windings, poses"]
        kv["kerosene-kv<br/>KeyValues, value encodings, I/O connections"]
        console["kerosene-console<br/>convars, commands, logging"]
    end
    subgraph L1["1 core services"]
        config["kerosene-config<br/>engine.kcfg"]
        vfs["kerosene-vfs<br/>search paths, archives, content root, toolchain"]
        platform["kerosene-platform<br/>store seam: Steam or none"]
    end
    subgraph L2["2 data and hardware"]
        resource["kerosene-resource<br/>compiled container, handles"]
        asset["kerosene-asset<br/>ktex, kmat_c, kmdl"]
        bsp["kerosene-bsp<br/>.kbsp + traces + vis + acoustics"]
        walk["kerosene-walk<br/>.kwalk + nav"]
        map["kerosene-map<br/>.kmap source (tools only)"]
        scene["kerosene-scene<br/>what the renderer is asked to draw"]
        rhi["kerosene-rhi<br/>device, surface, capture"]
        material["kerosene-material<br/>materials on the GPU"]
    end
    subgraph L3["3 subsystems"]
        render["kerosene-render<br/>mesh, lightmap, wgpu"]
        physics["kerosene-physics<br/>gamemovement, PhysicsWorld"]
        anim["kerosene-anim<br/>skeletons, clips"]
        audio["kerosene-audio<br/>mixer, reverb"]
        entity["kerosene-entity<br/>entity world + I/O"]
        script["kerosene-script<br/>Rhai layer"]
        ui["kerosene-ui<br/>game UI: layout, style, bindings"]
    end
    subgraph L4A["4 lifecycle"]
        lifecycle["kerosene-lifecycle<br/>start/stop order, managed threads, memory tracking"]
    end
    subgraph L4["5 host and game"]
        engine["kerosene-engine<br/>Engine, Game, host"]
        game["kerosene-game<br/>stock classes"]
    end
    facade["kerosene<br/>facade + Stock + launch"]
    runtime["apps/kerosene<br/>the runtime binary"]

    math --> kv
    kv --> config
    kv --> vfs
    vfs --> resource
    math --> asset
    kv --> asset
    resource --> asset
    math --> bsp
    kv --> bsp
    vfs --> bsp
    math --> walk
    math --> map
    kv --> map
    walk --> map
    config --> rhi
    asset --> material
    vfs --> material
    material --> render
    math --> anim
    asset --> anim
    math --> audio
    kv --> audio
    math --> physics
    bsp --> physics
    math --> entity
    kv --> entity
    math --> render
    asset --> render
    bsp --> render
    vfs --> render
    scene --> render
    math --> script
    platform --> script
    script -. "exception" .-> ui
    platform --> ui
    scene --> ui
    vfs --> ui
    math --> game
    kv --> game
    entity --> game
    L0 --> engine
    L1 --> engine
    anim --> engine
    asset --> engine
    audio --> engine
    bsp --> engine
    entity --> engine
    physics --> engine
    render --> engine
    resource --> engine
    rhi --> engine
    script --> engine
    ui --> engine
    walk --> engine
    engine -. "dev-dep, tests" .-> game
    engine --> facade
    game --> facade
    facade --> runtime

Read it top to bottom: nothing points back up, and nothing inside layer 3 points sideways except the one dashed exception. kerosene-math has two dependencies, both third-party (glam and bytemuck). kerosene-engine depends on almost everything and is depended on by nothing except the facade and the runtime. The facade re-exports every engine crate; those edges are left out. The tools are not in this graph at all, and that is the point — see Tools and the build.

The graph is not exactly Cargo.toml: kerosene-engine does not depend on kerosene-game. The stock game depends on the entity and kv crates, and the engine has it only as a dev-dependency for tests. kerosene::game::Stock is the type that joins the two at the facade level.

The four seams worth naming

1. The game seam

---
config:
  layout: elk
---
flowchart LR
    engine["kerosene-engine"] -- "defines trait Game" --> trait{{"Game:<br/>classes, setup, map_loaded,<br/>pre_tick, tick, entity_request,<br/>console_request, wants_ui, ui"}}
    game["kerosene-game"] -- "impl Game for Stock" --> trait
    your["your game"] -- "impl Game" --> trait
    facade["kerosene::launch"] -- "hands Box&lt;dyn Game&gt; to Engine" --> engine
    game -. "does NOT depend on engine" .-> engine

    classDef seam fill:#FF6D00,color:#fff
    class trait seam

crates/kerosene-engine/src/game.rs defines the trait. Every hook takes &mut Engine, which is what lets a game do anything the engine can. The price is stated in that file: the engine holds the game outside itself while a hook runs (Engine::with_game_mut), so a hook cannot cause another hook on the same game. tick must use Engine::request_map rather than Engine::load_map for that reason. A nested call is logged and skipped rather than deadlocking.

Handlers on entity classes are narrower still: they get &mut EntityWorld and nothing else, and leave a HostRequest when they need the rest of the engine. That keeps kerosene-entity a plain data structure, testable without an Engine. See Entities and scripting.

2. The platform seam

Engine in crates/kerosene-engine/src/engine.rs does not know what a window is. crates/kerosene-engine/src/host.rs adds winit, wgpu and egui on top. A dedicated server is Engine alone; --headless in launch.rs is exactly that. The rule is enforced by Engine simply having no surface field.

3. The content seam

---
config:
  layout: elk
---
flowchart TB
    caller(["any tool or the runtime"]) --> root["kerosene_vfs::root<br/>find the content tree"]
    root --> marker{"kerosene.kdef<br/>or maps/ + materials/?"}
    marker -- yes --> found["Found { root, why, project }"]
    marker -- "climb 6 levels" --> project["Project::read<br/>(.kproj names content)"]
    project --> found
    found --> vfs["Vfs<br/>mounted dirs + .vault archives"]

    caller2(["anything that launches another stage"]) --> tc["kerosene_vfs::toolchain"]
    tc --> sub["command(name)<br/>re-invoke this exe"]
    tc --> rt["runtime()<br/>sibling binary, then PATH"]

    classDef seam fill:#FF6D00,color:#fff
    class root,project,tc,found seam

crates/kerosene-vfs/src/root.rs exists because every tool used to infer the content directory independently and they disagreed; a tool looking in the wrong directory is indistinguishable from a broken tool. Found carries the why string so a wrong guess explains itself. crates/kerosene-vfs/src/toolchain.rs does the same for the two other binaries — a subcommand is always present because it is the executable re-invoking itself, and the runtime is a sibling first and on PATH second.

4. The format seam

Tools write formats; the engine reads them. kerosene-asset, kerosene-map and kerosene-bsp are shared by both sides, so the writer and reader are compiled from the same struct definitions. This is why kerosene-bsp has no GPU dependency and kerosene-render has no compile code: the format is the agreement, and both sides meet in a crate that knows only the format.

The facade

crates/kerosene/src/lib.rs is the game crate, and the API boundary: its public surface is what Kerosene’s version follows (see Versioning). It is deliberately thin, and draws one line:

  • the stable modules, re-exported whole: engine, entity, math, console, physics, script, ui, platform, vfs;
  • kerosene::internals, everything else — asset, audio, bsp, config, kv, map, render, walk — public but outside the promise;
  • the third-party crates a game names in its own signatures (glam, egui, rhai, winit, serde_json) so a game cannot end up linking two versions of glam;
  • pub mod game with Stock, the stock classes plus the Game impl;
  • pub mod tools behind the tools feature, for a game that ships an editor;
  • pub mod prelude, the handful of names most game code names, and VERSION.

It is #![warn(missing_docs)], and CI builds its docs with warnings denied.

The runtime binary apps/kerosene/src/main.rs is kerosene::launch(kerosene::game::Stock::default(), LaunchOptions::new(..)). Nothing else.

None of the workspace’s packages is published by itself (each says publish = false). cargo xtask bundle (xtask/src/bundle.rs) makes the one crate that is: it copies each package into src/__k/<module>/ of a kerosene crate under target/bundle/, turns its lib.rs into a mod.rs, rewrites crate:: and every other crate’s name into crate::__k::<module>::, gates the tool-only crates on the tools feature, flattens the features, and merges every dependency into one manifest. The facade’s lib.rs becomes the bundle’s root unchanged but for those paths, so its public face is the same in both. The tool packages are named kerosene-<tool>, with [lib] name keeping the short name; every internal dependency is pinned at =<version> and moved by scripts/bump-version.sh.

Crate summaries

CrateResponsibilityNotable source
kerosene-mathUnits, Plane/Winding/Aabb, angles, Pose, epsilon constantssrc/units.rs, src/plane.rs, src/winding.rs
kerosene-kvKeyValues parse/serialise, typed reads, format_float, the I/O Connection encodingsrc/parse.rs, src/value.rs, src/connection.rs
kerosene-consoleConVars, ConCommands, command buffer, log relay, crash handlersrc/lib.rs, src/logging.rs
kerosene-lifecycleModules started in phases and stopped in reverse, managed named threads and the worker Pool, the counting TrackingAllocatorsrc/manager.rs, src/thread.rs, src/pool.rs, src/mem.rs
kerosene-configengine.kcfg with defaults for every keysrc/lib.rs, src/renderer.rs
kerosene-vfsSearch-path stack, .vault archives, content discovery, toolchainsrc/lib.rs, src/root.rs, src/archive.rs
kerosene-resourceThe compiled resource container (header, typed blocks, references, source hash), Resource<T> handles and their cache, the asset-type tablesrc/container.rs, src/handle.rs, src/types.rs
kerosene-asset.ktex, .kmdl readers/writers; .kmat source and its compiled .kmat_csrc/texture.rs, src/material.rs, src/model.rs
kerosene-map.kmap source, brush ops (clip/carve/hollow), editor metadatasrc/solid.rs, src/ops.rs, src/editor.rs
kerosene-bsp.kbsp lumps, tree queries, traces, PVS, acoustics, sectionssrc/lib.rs, src/trace.rs, src/vis.rs
kerosene-walk.kwalk walkmap, navigation graph, the per-face WalkmapRulesrc/lib.rs, src/nav.rs, src/rule.rs
kerosene-sceneWhat the renderer is asked to draw: the UI display list and images, and the CPU-side frame data (camera, world mesh and culling, lightmaps, lights, probes, decals)src/draw.rs, src/mesh/, src/camera.rs, …
kerosene-rhiThe render hardware interface: opening a GPU, the device and surface, frame read-backsrc/gpu.rs, src/surface.rs, src/capture.rs
kerosene-materialMaterials on the GPU: the shared bind layout, loading a material’s maps, texture uploadsrc/maps.rs, src/texture.rs
kerosene-physicsSource gamemovement, CollisionWorld traitsrc/movement.rs, src/world.rs, src/rigid.rs (Box3D PhysicsWorld)
kerosene-entityEntity slots, fields, I/O queue, class registry, schema, save snapshotssrc/world.rs, src/io.rs, src/schema.rs, src/snapshot.rs
kerosene-renderCPU PVS/mesh build, lightmap atlas, dynamic lights, probes, wgpu backendsrc/mesh.rs, src/gpu.rs, src/lightmap.rs
kerosene-animskeleton, clip sampling, crossfade, skinning palettesrc/lib.rs
kerosene-audioADPCM, mixer, FDN reverb, device outputsrc/mixer.rs, src/reverb.rs, src/compiled.rs
kerosene-scriptRhai VM (and the one rhai dependency), world snapshot, ScriptAction queue, the platform object both script VMs registersrc/lib.rs, src/view.rs, src/bindings.rs, src/platform.rs
kerosene-uiGame UI: XML/CSS/Rhai documents, store and bindings, flexbox, glyph atlassrc/document.rs, src/bind.rs, src/style.rs
kerosene-platformThe store: Platform (validation, batching, events) over a Backend – Steam behind the steam feature, an offline stand-in otherwisesrc/lib.rs, src/steam.rs
kerosene-tooluiThe tools’ egui window host, theme and widgetssrc/lib.rs, src/theme.rs
kerosene-engineEngine, Game, host, launch, streaming, acoustics glue, saved gamessrc/engine.rs, src/host.rs, src/save.rs
kerosene-gameStock classes: doors, triggers, logic, props, soundsrc/doors.rs, src/logic.rs, src/props.rs
keroseneFacade, Stock, launch, preludesrc/lib.rs

Next: Runtime and the tick.

Runtime and the tick

The runtime is two layers with a hard boundary between them:

  • Engine (crates/kerosene-engine/src/engine.rs) is the whole simulation. It has no window, no GPU and no input device.
  • host (crates/kerosene-engine/src/host.rs) owns the winit event loop, the wgpu surface and egui, and calls Engine::frame once per redraw.

crates/kerosene-engine/src/lib.rs states the reason plainly: refusing to start a server without a GPU would be a serious mistake in an engine meant to host multiplayer games, so the boundary is enforced by Engine not knowing what a surface is. --headless in crates/kerosene-engine/src/launch.rs runs Engine alone for a fixed tick count.

From main to the first tick

---
config:
  layout: elk
---
flowchart TB
    main(["game main"]) --> launch["launch(game, LaunchOptions)"]
    launch --> logger["kerosene_console::install_logger<br/>+ install_crash_handler"]
    launch --> args["parse_args<br/>--content, --vault, --headless,<br/>+map &amp; +command"]
    args --> resolve["find content root,<br/>Project, engine.kcfg"]
    resolve --> choice{"--headless?"}
    choice -- yes --> headless["run_headless<br/>Engine::new + frame loop"]
    choice -- no --> host["host::run_with"]
    host --> engine["Engine::with_game(config, game)"]
    engine --> classes["game.classes(registry)"]
    engine --> modules["lifecycle: engine modules,<br/>then game.modules()"]
    engine --> setup["game.setup(&amp;mut engine)"]
    engine --> cfgs["exec config.cfg, autoexec.cfg,<br/>then startup_commands"]
    cfgs --> loop{{"winit event loop"}}

    classDef proc fill:#FF6D00,color:#fff
    classDef data fill:#2962FF,color:#fff
    class launch,args,resolve,headless,host,classes,modules,setup,cfgs proc
    class logger,engine data

Engine::with_game is where the order is decided. The game is asked for its classes and gets setup after the console exists but before config.cfg, autoexec.cfg and the command line run — so a convar a game registers in setup can be set from any of the three. The VFS is built first and wrapped in an Arc; archives mount after directories so loose files win.

Between the two, kerosene-lifecycle starts the background threads: the engine’s own modules (the streaming workers), then the game’s (Game::modules), so a game’s setup runs with everything it builds on already up. Engine::shutdown is the mirror image: the game’s shutdown hook, then the modules in reverse, each with its threads signalled and joined. See Threads and memory.

The fixed tick vs the render frame

Source’s defining property, kept exactly: simulation runs at a fixed rate and rendering runs as fast as it can, interpolating between the last two simulation states.

---
config:
  layout: elk
---
flowchart LR
    subgraph host["host.rs: one RedrawRequested"]
        dt["real_dt = now - last, capped at 0.25s"]
        frame["Engine::frame(real_dt, input)"]
        draw["draw(real_dt)"]
        dt --> frame --> draw
    end
    subgraph core["engine.rs: Engine::frame"]
        drain["drain log relay"]
        runbuf["console.run_buffered()"]
        pending["load_pending_map()"]
        acc["accumulator += real_dt<br/>cap: interval * 8"]
        while{{"while accumulator &gt;= interval"}}
        tick["Engine::tick(interval, input)"]
        drain --> runbuf --> pending --> acc --> while --> tick
        tick --> while
    end
    frame --> drain
    draw --> alpha["interpolation_alpha()<br/>accumulator / interval"]

The accumulator cap is deliberate: a breakpoint or a window drag must not produce hundreds of catch-up ticks that look like the world fast-forwarding. The cap is tick_interval() * 8.0 in Engine::frame.

Engine::tick_rate reads sv_tickrate (default 64, DEFAULT_TICKRATE). Engine::interpolation_alpha returns accumulator / tick_interval() in 0..=1; the host uses it to blend the view (Engine::interpolated_eye) and brush-model poses (Engine::interpolated_brush_model_poses). The host takes the latest input for view angles and blends only positions, which is why the camera feels responsive at frame rate rather than tick rate.

One tick, in order

Engine::tick reads top to bottom in crates/kerosene-engine/src/engine.rs. The order is not arbitrary; several steps only work because of where they sit.

---
config:
  layout: elk
---
flowchart TB
    A["time += dt; tick_count += 1<br/>previous_origin = movement.origin"] --> B["view_angles = input.view_angles.clamped_view()"]
    B --> C["entities.set_trace(developer &gt;= 2)"]
    C --> D["game.pre_tick(engine, input, dt)"]
    D --> E["build PlayerCollision for this tick"]
    E --> F["movement_params() from sv_* convars"]
    F --> G["kerosene_physics::player_move(...)"]
    G --> H["fall damage if landed fast"]
    H --> I["update_footsteps(dt)"]
    I --> J["write player origin back to its entity"]
    J --> K["audio.set_listener(eye, view basis)<br/>set_volume; update_acoustics()"]
    K --> L["use key edge → use_what_is_in_front()"]
    L --> M["attack edge → throw_held_prop()"]
    M --> N["snapshot previous_brush_poses"]
    N --> O["update_triggers(dt)"]
    O --> P["entities.run(dt)<br/>deliver events, thinks, reclaim removals"]
    P --> Q["take_entity_requests()<br/>engine host_requests"]
    Q --> R["game.tick(engine, input, dt)"]
    R --> S["physics: sync_player, push_props,<br/>steer held prop, sync_and_step"]
    S --> T["update_streaming(dt)"]
    T --> U["script on_tick hook, if defined"]

    classDef movement fill:#2962FF,color:#fff
    classDef entities fill:#AA00FF,color:#fff
    classDef physicsC fill:#00C853,color:#fff
    class G,H,I movement
    class P,Q,R,U entities
    class S physicsC

Points worth calling out:

  • pre_tick runs before movement specifically so a game can change speed, gravity or jump permission before the movement reads the convars (movement_params).
  • game.tick runs after entity I/O but before the rigid-body step. The module doc in game.rs states this: “the player has moved, triggers have fired, entities have thought and their requests are answered. Physics props have not yet stepped.”
  • previous_brush_poses is snapshotted before entities.run, so a door that thinks and moves this tick has both endpoints for interpolation.
  • The player’s box is synced into the rigid world before props step, so a prop meets the player where they are this tick. Solidity and pushing are separate: sync_player makes props bounce off the player, push_props lets the player move them. player_push_direction reads the requested direction, not velocity, because a crate stops the player dead and reading velocity would make leaning on a crate do nothing.

The Game trait

Defined in crates/kerosene-engine/src/game.rs; every method has a do-nothing default, and () implements it (what Engine::new uses).

HookWhenTypical use
classes(&mut ClassRegistry)once, at engine constructionregister entity classes
schema() -> &'static strtools only.kdef text for the inspector
setup(&mut Engine)console up, no map loadedregister convars/commands
map_loaded(&mut Engine)after entities spawn, player placed, map script ranper-map state
pre_tick(&mut Engine, &InputState, dt)before movementchange movement
tick(&mut Engine, &InputState, dt)after I/O, before props stepgame rules
entity_request(&mut Engine, &HostRequest) -> boolunclaimed entity requestcustom host verbs
console_request(&mut Engine, kind, payload) -> boolunclaimed console requestcustom console verbs
wants_ui() -> boolevery frameopt into the UI layer
ui(&mut Engine, &egui::Context)when wanted / mouse freeHUD, menus

The re-entrancy rule is implemented in Engine::with_game_mut:

#![allow(unused)]
fn main() {
pub(crate) fn with_game_mut<R>(
    &mut self,
    f: impl FnOnce(&mut dyn Game, &mut Engine) -> R,
) -> Option<R> {
    let Some(mut game) = self.game.take() else { /* log + skip */ return None };
    let out = f(game.as_mut(), self);
    self.game = Some(game);
    Some(out)
}
}

self.game is Option<Box<dyn Game>>, None only while a hook runs. Engine::game() returns None during a hook. map_loaded loading another map must go through Engine::request_map, which sets pending_map and is drained by load_pending_map at the top of the next frame — before the accumulator, so the map change cannot be missed by a long frame.

Requests: console and entity

The engine’s own I/O is request-shaped for the same reason the game seam is: a handler must not be able to reach arbitrarily back into the engine.

---
config:
  layout: elk
---
flowchart LR
    con["Console command handler"] -- "con.request(kind, payload)" --> out["Console outbox"]
    ent["Entity input/think handler"] -- "world.request(kind, payload, caller, activator)" --> req["EntityWorld.requests"]
    out --> takeC["take_console_requests()"]
    req --> takeE["take_entity_requests()"]
    takeC --> hostR{"host owns kind?"}
    hostR -- yes --> host["host: toggleconsole, bind, ..."]
    hostR -- no --> gameR{"game.console_request?"}
    gameR -- no --> report["report_unhandled() → console"]
    takeE --> engineK{"engine host_requests::*?"}
    engineK -- yes --> eng["script, play_sound, stop_sound, phys_wake/sleep"]
    engineK -- no --> gameE{"game.entity_request?"}
    gameE -- no --> report

    classDef data fill:#2962FF,color:#fff
    classDef proc fill:#FF6D00,color:#fff
    class out,req data
    class takeC,takeE,host,eng proc

host_requests in crates/kerosene-entity/src/world.rs is the short list the engine knows (script, script_call, script_file, play_sound, stop_sound, phys_wake, phys_sleep). Anything else is offered to the game and, if declined, printed as unknown. The console’s requests module is the same shape, and the host claims toggleconsole, bind, unbind, unbindall and bind_list — that is why the console toggle is bindable like any command.

Input and the console

crates/kerosene-engine/src/input.rs holds InputSystem. Keyboard and mouse events name a command and console.execute_user runs it; movement uses convars through HeldActions/InputState rather than hard-coded keys, so +forward is rebindable. The host intercepts the console key (intercepted()) before anything else, on press and not repeat, because a focused text field otherwise eats the very key that closes the console. While the console is open the game sees no keys and held movement is released; the same happens on WindowEvent::Focused(false) so an alt-tab does not leave the player running.

The host frame

App::frame in crates/kerosene-engine/src/host.rs:

  1. real_dt from Instant, capped at 0.25 s.
  2. input.update_view(&console) and input.state().
  3. set_host_paused(console open || background) and set_background(..): the engine decides from those, the pause menu and sv_pause_on_menu whether it is paused (Engine::is_paused). Paused, frame runs the console and pending loads but no ticks, and empties the accumulator so unpausing is not a burst of catch-up ticks.
  4. engine.frame(real_dt, &input_state) — zero or more ticks.
  5. Drain console requests the engine did not claim (host verbs), report the rest.
  6. quit_requested() → exit.
  7. Rebuild GPU resources if load_generation() changed (by generation, not name, so reloading a recompiled map shows new geometry).
  8. stream_sections() — build wanted sections on worker threads, upload on this thread.
  9. draw(real_dt), unless the window is occluded (minimised or covered), when it sleeps 20 ms instead.
  10. Sleep to honour fps_max, measured from the frame’s start.

rebuild_map builds section 0 (the world) synchronously and records LoadedMap { generation, sections, building, tx, rx }. stream_sections spawns a thread per wanted, not-yet-resident section to build WorldMesh + LightmapAtlas from the shared Arc<Bsp>, and uploads when the mpsc receiver delivers. Engine::section_loaded then calls sync_sections so the rigid hulls match what is resident. See Rendering and streaming.

Next: The map pipeline.

The map pipeline

A .kmap is edited by hand or by Chisel; a .kbsp is what the engine loads. In between sit four compilers, each a separate crate and a separate subcommand, each writing back into the same file. This page follows the data.

---
config:
  layout: elk
---
flowchart LR
    map(["maps/x.kmap<br/>KeyValues, brushes as planes"]) --> cleave["cleave"]
    cleave --> bsp0(["x.kbsp<br/>+ x.kprt<br/>+ x.kwalk"])
    bsp0 --> umbra["umbra"]
    bsp0 --> resonance["resonance"]
    umbra --> bsp1(["x.kbsp<br/>+ visibility"])
    bsp1 --> resonance
    resonance --> bsp2(["x.kbsp<br/>+ acoustics"])
    bsp2 --> radiance["radiance"]
    bsp1 --> radiance
    radiance --> bsp3(["x.kbsp<br/>+ lighting"])
    bsp3 --> vault["vault"]
    vault --> archive(["content.vault"])
    archive --> engine(["kerosene runtime"])

    classDef tool fill:#FF6D00,color:#fff
    classDef file fill:#2962FF,color:#fff
    class cleave,umbra,resonance,radiance,vault tool
    class map,bsp0,bsp1,bsp2,bsp3,archive file

Resonance reads the portal file Umbra also needs, so it runs after Cleave and is otherwise independent of Umbra; the engine’s load_map only warns if any of the three data sets is missing (bsp.visibility.is_empty(), bsp.lighting.is_empty(), bsp.acoustics.is_none()), because a map is still playable unlit and unseen.

Cleave: .kmap → .kbsp

Source of truth: tools/cleave/src/pipeline.rs, function compile. The stages run in this order and the code comments mark each with // ---- name ----.

---
config:
  layout: elk
---
flowchart TB
    cordon["cordon: clip the map to a box, sealed by its own walls"] --> brush["BrushWork::from_solid*<br/>intern half-space planes"]
    brush --> sect["SectionTable::of / assign<br/>tag every brush with its streamed section"]
    sect --> csg["csg::chop_brushes<br/>remove buried face fragments"]
    csg --> split{"structural vs detail"}
    split -- "structural (world)" --> tree["Tree::build<br/>Quake plane heuristic"]
    split -- "detail, clip, trigger, water" --> hold["held out; filed into leaves later"]
    tree --> portals["portal::build_portals<br/>polygon per leaf-leaf touch"]
    portals --> flood["portal::flood_entities<br/>leak detection from every entity origin"]
    flood --> fill["portal::fill_outside<br/>(only if sealed)"]
    fill --> clusters["portal::assign_clusters"]
    clusters --> file["filter_brush: non-structural brushes into leaves"]
    file --> models["brush entities → per-model BrushModel"]
    models --> emit["emit::emit<br/>file faces, weld, write lumps"]
    emit --> out1(["Bsp, .kprt text, .kwalk"])

    classDef stage fill:#FF6D00,color:#fff
    classDef decision fill:#AA00FF,color:#fff
    class cordon,brush,sect,csg,tree,portals,flood,fill,clusters,file,models,emit stage
    class split decision

Brushes are planes

kerosene_map::Solid stores each face as three points; BrushWork (tools/cleave/src/brush.rs) interns those into a PlaneSet and computes the face polygon. Plane interning is not a nicety: two faces meant to be coplanar must share one plane index, or the tree splits along a hair’s width and the compile explodes. kerosene-math/src/plane.rs holds PlaneSet and the dedup/episode logic.

CSG rules for coplanar faces

tools/cleave/src/csg.rs documents the two subtle cases that decide whether flush brushes z-fight or leak:

  • same plane, same facing (brushes side by side): lower-index brush keeps the shared area, the other drops it — exactly one face survives;
  • same plane, opposite facing (back to back): both drop it, it is an interior seam nothing can see.

The tree heuristic

tools/cleave/src/tree.rs chooses at each step the plane that scores best on Quake’s heuristic: coplanar faces are worth a lot, splits cost, axial planes get a bonus. Detail brushes are excluded from Tree::build entirely — “the single biggest lever a level designer has over compile time”, as kerosene-bsp/src/lib.rs puts it. A leaf learns it is solid in the Quake way: a solid brush whose every side was used as a node plane on the path down has been completely carved out of space.

Portals, leaks, outside removal

tools/cleave/src/portal.rs builds portals from six world-box polygons down the tree. Flooding from every entity origin answers three questions at once: is the map sealed, which leaves are inside, and what is the connectivity graph. If the flood reaches outside and ignore_leaks is false, Cleave returns CompileError::Leaked before filling, because filling a leaking map turns the whole level solid. Only a sealed map gets fill_outside; assign_clusters then numbers the surviving leaves for Umbra.

Emission

tools/cleave/src/emit.rs does three jobs in order: file each CSG fragment down the tree into the leaf that can see it (dropping fragments in solid leaves), weld vertices/edges so seams stay watertight (WELD_EPSILON 0.05, well under ON_EPSILON), and write the lumps. The .kprt is written by portal::write_prt; the .kwalk by tools/cleave/src/walk.rs from the same final polygons — so a face CSG cut back to a sliver contributes only the sliver.

Meshes

tools/cleave/src/mesh.rs compiles the world’s meshes beside the brushes. Each face is cut into flat convex pieces (Mesh::face_pieces: the face itself, or ear-clipped triangles), and each piece becomes two things:

  • a drawn face, filed down the tree by emit exactly as a detail brush’s fragment is – lit by Radiance, drawn by the renderer, and in the walkmap when it is ground (walk::collect_with_meshes);
  • a collision slab: an invisible convex detail brush, the piece for a front, SLAB_THICKNESS (8) behind it, a side through each edge, every side wearing the face’s material so a trace reports it.

Slabs join the world brushes before sections are compacted (so a streamed mesh keeps its section) but are marked from_mesh, which csg::should_cut reads: a slab never erases another brush’s face. Neither kind ever enters Tree::build, so a mesh cannot change the tree, the portals or the PVS.

Tool materials

tools/cleave/src/material.rs maps a material name to compiler intent: everything under tools/ is special (tools/clip, tools/hint, tools/skip, …), everything else is ordinary geometry. This is Source’s convention and it is good design: the level designer expresses intent with the same tool they use for everything else, and it is visible in the 3D view.

Sections and the cordon

tools/cleave/src/sections.rs numbers streamed visgroups after the world (section 0) and tags each brush and face. A brush may also name its section with "section" "name". The cordon is the editor’s “compile only this box”: the box’s own walls seal it so the result does not leak. See Rendering and streaming for what the engine does with sections.

Umbra: the PVS

Source: tools/umbra/src/lib.rs, flow.rs, prt.rs, bitset.rs. It reads the .kbsp and the .kprt, and writes the visibility lump.

Two passes that tighten on each other (tools/umbra/src/flow.rs):

  1. Base vis — cheap and generous: two portals might see each other if each pokes out on the visible side of the other; flood transitively for an over-estimate.
  2. Portal flow — the real answer. Sight from the source portal through a chain of portals is a shrinking sight cone, narrowed by separating planes (a plane touching one edge of the source and one vertex of the portal being passed through). When the cone closes to nothing, everything beyond is invisible and the recursion stops. MAX_DEPTH = 128 caps degenerate graphs; stopping early is conservative (leaves extra clusters visible), not wrong.

The result is a bit per cluster pair, run-length encoded on zero bytes — the Quake/Source scheme. --fast stops after base vis for iteration. See BSP internals and traces for the VisData reader.

Resonance: acoustics

Source: tools/resonance/src/. It runs after the portal graph exists and independently of Radiance — sound and light share nothing but the walls.

---
config:
  layout: elk
---
flowchart LR
    leaves["every non-solid leaf"] --> probe["probe.rs<br/>N rays out, bounce until energy runs out"]
    probe --> perleaf["LeafAcoustics:<br/>mean free path, mean absorption/band,<br/>openness, first-reflection time"]
    perleaf --> eyring["Eyring's formula → rt60 per band"]
    eyring --> rooms["rooms.rs<br/>union-find over portal graph,<br/>widest portals first, merge while alike"]
    rooms --> rec(["AcousticRoom records<br/>+ leaf→room table"])
    mats["materials.rs<br/>.kmat $surfaceprop / $acoustics → absorption per band"] --> probe

    classDef proc fill:#FF6D00,color:#fff
    classDef data fill:#2962FF,color:#fff
    class probe,rooms,eyring proc
    class perleaf,mats,rec data

A ray meets a surface every mean_free_path units and loses ln(1 - absorption) per hit, so energy falls as exp(distance * mean_ln / mean_free_path); 60 dB is ln(10^-6) of that and distance is time at the speed of sound (SPEED_OF_SOUND = 13_504.0 ku/s). Air absorption is one more loss per unit, tabulated per band. The averages are what Eyring’s formula wants, measured in the room’s actual shape rather than assumed for a box.

Leaves are then gathered into rooms (tools/resonance/src/rooms.rs): union-find over the portal graph, widest portals first, merging only while the merged average still sounds like both halves. A hall the BSP cut into twenty leaves becomes one hall; a doorway stays a boundary because the two sides disagree. Leaves too small to probe inherit from the neighbour they share the most portal with. The record layout is in kerosene-bsp/src/acoustics.rs; the mixer reads it directly.

Radiance: lightmaps

Source: tools/radiance/src/bake.rs, lights.rs. Lights are authored as entities (light, light_spot, light_environment) and read from the compiled entity lump by LightSet::from_bsp. Every lit face carries a grid of luxels; baking one means finding where it sits in the world and asking every light whether it can see it, then bouncing.

Two details do most of the work of making it look right:

  • Samples land on the surface, not inside it. The luxel grid covers the face’s bounding rectangle in texture space, so points near the edge of an angled face fall inside neighbouring geometry and bake black, producing a dark rim. They are nudged toward the face centre until they clear solid.
  • Shadow rays start slightly off the surface. Starting exactly on the face means the first hit is the face itself and every surface is fully shadowed.

--samples (supersampling, 1–8) softens shadow edges at quadratic cost; --bounces controls indirect light; --fast is one sample and no bounces. The result is ColorRgbExp32 per luxel in the lighting lump, packed at runtime into an atlas (crates/kerosene-render/src/lightmap.rs). Sun direction and sky colour come from light_environment; the engine reads the same _light colour to tint the sky (Engine::sky_color_from_map), so a map lit by a warm sun gets a warm sky without anyone stating it twice.

Last, tools/radiance/src/probes.rs bakes the reflection probes: one per env_cubemap, six faces of --cubemap-size texels, each texel a ray from the probe. A ray that escapes is sky; one that lands is the lightmap at that point times the surface’s reflectivity. The face it landed on is found through a FaceIndex bucketed by plane, not through the leaf in front of the hit: a hit an epsilon off the floor can sit in a thin leaf Cleave filed no faces into. Only world faces are indexed, since a door is baked where it was compiled. The result goes in the cubemaps lump; a map with no probes has any stale ones cleared.

Kiln: the whole pipeline

Source: tools/kiln/src/lib.rs. Kiln is the one tool that runs the others. It re-invokes the same executable with each stage’s name as a subcommand, so “the pipeline is one program” never leaks into the compilers being separate stages. The stages are Textures, Sounds, Models, Maps, Pack (Stage::ALL) and Ship (Stage::EVERY); Ship is deliberately not in ALL, because building content is something done every few minutes and assembling a distribution is not.

---
config:
  layout: elk
---
sequenceDiagram
    participant K as kiln
    participant A as alchemy (library)
    participant T as timbre (library)
    participant S as kerosene-tools subprocesses
    K->>A: compile textures + texture sets
    K->>T: compile sounds
    K->>S: forge models
    K->>S: cleave → umbra → resonance → radiance (per map, parallel)
    K->>S: vault pack
    Note over K: --ship also builds the game package

Only the texture and sound builds are library calls, and for a stated reason in tools/kiln/src/lib.rs and tools/alchemy/src/lib.rs: Chisel and Timbre’s own GUI make the same calls, and two callers of one step must not be able to disagree. Everything else is a subprocess so a compiler that fails does not take the build down with it.

Chisel and the editor-side compilers

tools/chisel embeds the same algorithms as libraries so the editor can draw previews and run a compile from F9 without shelling out. Its compile module drives Cleave/Umbra/Radiance as subcommands through kerosene_vfs::toolchain, which is the same path Kiln uses. Chisel depends on alchemy in-process because it builds the content tree’s textures on the way to opening its window, so the editor still works when the only binary present is itself (tools/chisel/Cargo.toml comments this explicitly).

Next: BSP internals and traces.

BSP internals and traces

kerosene-bsp is the format the engine runs. It has no GPU dependency and no compiler dependency: it reads and writes .kbsp, answers tree queries, and traces. This page is the file layout, the queries built on it, and the trace algorithm that everything collision-shaped funnels through.

The file

crates/kerosene-bsp/src/io.rs writes a header, a lump directory, then the lumps. MAGIC = "KROS", VERSION = 2, LUMP_COUNT = 24. Each LumpDir entry is offset: u32, length: u32, version: u32, ident: [u8; 4]. A lump carries its own version so a bump to one does not invalidate the rest.

Because every lump is an array of #[repr(C)], padding-free bytemuck::Pod records (crates/kerosene-bsp/src/types.rs), loading is a bounds check and a cast rather than a parse. The size_and_alignment test enforces padding-free layout, because a silently inserted pad byte would shift every subsequent record and corrupt the map in ways that look like geometry bugs.

#LumpContents
0entitiesKeyValues text, one entity block each
1planesBspPlane { normal, dist, kind }
2vertices[f32; 3]
3edgesEdge { v: [u32; 2] }
4surfedgessigned indices into edges; negative = backwards
5facesFace (plane, side, surfedge range, texinfo)
6nodesNode (plane, two children, bounds)
7leavesLeaf (contents, cluster, leafbrush range)
8–9leaffaces, leafbrushesindirection from leaf to faces/brushes
10modelsModel, one per brush entity plus world (0)
11–12brushes, brushsidesconvex brushes and their planes
13–15texinfo, texdata, texdata_stringsmaterial binding
16visibilityRLE PVS/PAS
17lightingColorRgbExp32 per luxel
18–19acoustics, acoustic_leafsrooms and leaf→room
20–22sections, face_sections, brush_sectionsstreaming
23cubemapsreflection probes (KCUB), empty without env_cubemaps

The indirection in lumps 3–4 is the important one. Vertices are reached as surfedges[first_surfedge .. + num_surfedges], each a signed edge index, negative meaning “walk the edge backwards”. Two faces meeting at an edge share one edge record and one pair of vertex positions, which keeps their seam watertight no matter how the float arithmetic rounds. Bsp::face_vertices walks exactly this.

The tree

An interior Node has a plane and two children; a leaf is encoded as a negative child index. decode_child/encode_leaf in types.rs do the encoding, and Child::{Node, Leaf} is the decoded form.

---
config:
  layout: elk
---
flowchart TB
    q(["point_leaf_from(point, head)"])
    q --> dec{"decode_child"}
    dec -- Node --> plane["plane.distance_to(point) >= 0 ?<br/>children[0] : children[1]"]
    plane --> dec
    dec -- Leaf --> leaf(["leaf index (clamped to valid range)"])
    leaf --> cluster["point_cluster → Leaf.cluster (-1 = solid)"]
    leaf --> contents["point_contents → Leaf.contents"]
    leaf --> room["room_at → Acoustics.room_of_leaf"]

    classDef q fill:#2962FF,color:#fff
    classDef d fill:#AA00FF,color:#fff
    class q,leaf,cluster,contents,room q
    class dec,plane d

Bsp::point_leaf_from is bounded by nodes.len() + 1 rather than loop, so a cyclic tree from a broken compile returns a wrong answer instead of hanging the engine. Every position update, sound and visibility test starts here, and it is tree-depth dot products, not geometry count — the whole reason the BSP exists.

Visibility queries

crates/kerosene-bsp/src/vis.rs is the reader. Rows are bit-vectors, one bit per cluster, run-length encoded on zero bytes — nearly free to decode and enormously effective because a typical row is mostly zeroes.

  • VisData::decompress(cluster, VisKind::Pvs) gives the row.
  • Bsp::visible_leaves(from_cluster) turns it into leaf indices.
  • Bsp::cluster_audible uses VisKind::Pas — the PVS flooded one extra step, the Potentially Audible Set. Sound goes around a corner where sight does not, and streaming uses the PAS as a one-doorway warning margin.
  • With no VisData, both return everything. An un-vised map still renders, it just loses culling.

Traces

crates/kerosene-bsp/src/trace.rs implements tracing against brushes, not triangles. A brush is a handful of planes, so testing one is a handful of dot products regardless of how detailed its surface is, and the BSP narrows the search to the brushes along the path.

---
config:
  layout: elk
---
flowchart TB
    entry(["trace_box(start, end, mins, maxs, mask)"]) --> setup["Query { start, end, mins, maxs, mask }<br/>extents = per-axis max reach"]
    setup --> recurse{{"recurse(node, p1f, p2f, p1, p2)"}}
    recurse --> pruned{"fraction &lt;= p1f?"}
    pruned -- yes --> done(["return: a nearer hit already exists"])
    pruned -- no --> leaf{"decode_child"}
    leaf -- "Leaf" --> test["test_leaf: skip brushes whose<br/>contents &amp; mask == 0"]
    test --> clip["clip_to_brush per brush"]
    clip --> enter{"enter_frac &lt;= leave_frac?"}
    enter -- yes --> hit["record fraction, plane,<br/>contents, surface_flags, texture_index"]
    enter -- no --> test
    leaf -- "Node" --> classify["offset = extents · |normal|<br/>both sides >= offset → front only<br/>both &lt; -offset → back only"]
    classify --> split["crossing: split at t1/t2,<br/>walk near side first"]
    split --> recurse

    classDef proc fill:#FF6D00,color:#fff
    classDef data fill:#2962FF,color:#fff
    classDef decision fill:#AA00FF,color:#fff
    class setup,test,clip,hit,classify proc
    class entry,done data
    class recurse,pruned,leaf,enter,split decision

The box trace uses the standard trick: push each brush plane outward by the box’s extent along that plane’s normal (offset = extents·|normal|), which turns a swept-box test back into a swept-point test against a fattened brush. Walking the near side first lets an early hit prune the far side.

clip_to_brush tracks where the path enters the brush (latest front-to-back crossing) and where it leaves (earliest back-to-front). If it enters before it leaves, it is inside and the entry point is the hit. One subtlety is recorded in the code comment: the early-out tests fraction == 0.0, not all_solid, because all_solid starts true and is only cleared by visiting open space — testing it returned after the first brush of every solid leaf, and a door built from two brushes only ever collided with one of them.

Contents and surface flags

crates/kerosene-bsp/src/types.rs defines contents and surf. They are flags, not enums, because one brush can be several things at once: water that also blocks bullets, a grate that blocks players but not sight. Masks are named for the question being asked:

MaskIncludesUsed by
MASK_PLAYER_SOLIDSOLID, MOVEABLE, PLAYER_CLIP, WINDOW, GRATEplayer movement
MASK_SHOTSOLID, MOVEABLE, WINDOW, GRATEbullets, pick-up reach
MASK_OPAQUESOLID, MOVEABLE, OPAQUEline of sight, shadow rays
MASK_WATERWATER, SLIMEwater level
MASK_VOLUMESWATER, SLIME, LADDERnon-solid point queries

DETAIL = 1 << 27 marks brushes kept out of the tree entirely — a railing or pillar would otherwise carve the world into slivers and inflate vis for no benefit, since you cannot hide behind a railing. Detail geometry renders and collides but does not split space.

Brush entities are separate models

A door, lift or rotating brush is compiled as its own Model with its own leaves, and its leaves are not in the world’s PVS. It is traced separately (Bsp::trace_model) and the nearest hit taken. This is why the renderer draws brush entities in a second pass: a leaf walk finds the world and nothing else. Forgetting that is how you walk through every door in the game, and — per the architecture.md war story — how every brush entity was once built into the mesh and never drawn.

Point contents

Bsp::point_contents_brushes asks what is at a point directly, which is how non-solid volumes are found: water, slime and ladders never appear in a movement trace, so they need a query of their own rather than riding along with MASK_PLAYER_SOLID.

Acoustics and sections

Two lumps carry compiler output the engine reads whole:

  • crates/kerosene-bsp/src/acoustics.rs — AcousticRoom per room behind an AcousticHeader, plus one u16 per leaf (NO_ROOM for solid). A room’s record is everything the mixer’s reverb needs in the units the reverb takes, and it keeps what it was derived from (absorption per band, mean free path) so a designer can ask why a corridor rings.
  • crates/kerosene-bsp/src/sections.rs — SectionRecord per section, one u16 per face and per brush. A map with no streamed visgroups still writes one section holding everything, and a file missing the lumps reads the same way, so nothing special-cases an older map.

Next: Entities and scripting.

Entities and scripting

Entities are the half of a level that is not geometry. kerosene-entity owns the data structure and the I/O graph; kerosene-game decides what the names mean. This page is the storage model, the event queue, the class registry, and the Rhai layer above it.

The storage model

crates/kerosene-entity/src/world.rs:

---
config:
  layout: elk
---
flowchart LR
    subgraph world["EntityWorld"]
        slots["slots: Vec&lt;Option&lt;Entity&gt;&gt;"]
        gens["generations: Vec&lt;u32&gt;"]
        free["free: Vec&lt;u32&gt;"]
        names["by_name: HashMap&lt;lowercase, Vec&lt;EntityId&gt;&gt;"]
        queue["queue: BinaryHeap&lt;PendingEvent&gt;"]
        reqs["requests: Vec&lt;HostRequest&gt;"]
    end
    spawn(["spawn(classname)"]) --> slots
    spawn --> free
    get(["get(EntityId)"]) --> slots
    get --> gens
    remove(["remove(id)"]) --> pending["mark pending_removal"]
    pending --> reclaim["reclaim_removed()<br/>bump generation, push to free"]

    classDef data fill:#2962FF,color:#fff
    class slots,gens,free,names,queue,reqs data

EntityId { index, generation } is a generational handle. The generation is bumped in reclaim_removed, so a handle to a removed entity fails to resolve rather than silently addressing whoever moved into the slot. Entity references outlive entities constantly — a queued event naming an entity that dies before it fires is routine — and without the generation those become use-after-free bugs with no crash to point at them.

Removal is deferred: remove() sets pending_removal, and the slot is only reclaimed at the end of the tick, so handlers mid-dispatch never find it vanished underneath them.

Entity carries origin, angles, fields, connections, next_think, brush_model and pending_removal. brush_model: Option<usize> is set from a "model" "*N" key; anything else stays a plain field (a studio model path).

Fields are a bag, not a struct

crates/kerosene-entity/src/value.rs defines Value (Bool, Int, Float, Text, Vector, Angle) and Fields, a HashMap<String, Value>. The set of meaningful fields belongs to the game, not the engine: a mod adds a field by writing it in the editor, and nothing in the engine changes. This is Source’s datadesc idea with the boilerplate removed. Value::Display formats numbers through kerosene_math::format_float, so a float that happens to be whole does not read as 250.000000.

The class registry

crates/kerosene-entity/src/registry.rs maps a classname to a ClassDef: optional spawn/think handlers, a list of (input_name, handler), and a list of output names. Handlers are plain fn pointers taking `&mut EntityWorld and an id — narrow on purpose, so the entity world stays testable without an engine.

---
config:
  layout: elk
---
flowchart TB
    load["load_from_lump / load_from_kv"] --> create["create_entities:<br/>spawn slots, parse fields + connections"]
    create --> bounds["fill model_mins/model_maxs on brush entities"]
    bounds --> spawnH["run_spawn_handlers (deferred, all entities exist)"]
    spawnH --> ready{{"EntityWorld ready"}}
    tick["EntityWorld::run(dt)"] --> dispatch["dispatch_due()<br/>pop due events, resolve, accept_input"]
    dispatch --> thinks["run_thinks()<br/>due next_think handlers"]
    thinks --> reclaim["reclaim_removed()"]

    classDef proc fill:#FF6D00,color:#fff
    classDef decision fill:#AA00FF,color:#fff
    class load,create,bounds,spawnH,dispatch,thinks,reclaim proc
    class ready decision

Spawn handlers are deferred until every entity exists, so one entity can find another by name during its own spawn (a door finding its button). Brush entities get their model bounds as model_mins/model_maxs before spawn handlers run, because a class like door needs to know how far it travels and that comes from geometry, not a keyvalue.

The registry declares outputs (ClassDef.outputs) even though firing one is just a string passed to EntityWorld::fire_output. Declaring keeps the editor’s schema honest: a test checks the game’s outputs against the .kdef schema, so adding an output and forgetting to offer it in Chisel is a build failure rather than a wiring session that silently does nothing.

The event queue

crates/kerosene-entity/src/io.rs defines Connection, InputEvent, PendingEvent and Target. Outputs become queued events even at zero delay, for two reasons the module doc states: an entity firing an output at itself cannot recurse into the stack, and ordering is the same whether a delay is zero or not.

---
config:
  layout: elk
---
flowchart LR
    fire(["fire_output(caller, output, activator, param)"]) --> match["match connections by<br/>output name, not exhausted"]
    match --> push["push PendingEvent{<br/>fire_at = time + delay, target, input,<br/>activator, caller, sequence }"]
    push --> dec["decrement times_to_fire now,<br/>not on delivery"]
    queue[("BinaryHeap&lt;PendingEvent&gt;<br/>min fire_at, then sequence")] --> dispatch["dispatch_due()"]
    dispatch --> resolve["resolve(Target) → Vec&lt;EntityId&gt;"]
    resolve --> accept["accept_input(id, InputEvent)"]
    accept --> handler["registry.find_input(classname, name)"]

    classDef data fill:#2962FF,color:#fff
    classDef proc fill:#FF6D00,color:#fff
    class queue data
    class fire,match,push,dec,dispatch,resolve,accept,handler proc

PendingEvent::Ord is reversed so the BinaryHeap (a max-heap) yields the earliest first, then ties on sequence so two events at the same instant keep the order they were queued in — otherwise a door and its sound can swap between runs. MAX_EVENTS_PER_TICK = 4096 guards a relay loop: when exceeded, the engine logs an error and clears the queue, leaving the level playable.

Target resolves to potentially several entities: Named fires every entity sharing the name (how one wire opens six doors), and the special names are !activator, !caller, !self, !player. Target::Handle is not reachable from a map file; scripts need it because acting on one of a dozen unnamed lights has to mean that one.

The game seam, from the entity side

A class handler gets &mut EntityWorld and nothing else. When it needs more — a script, a sound, a physics nudge — it leaves a HostRequest:

---
config:
  layout: elk
---
flowchart LR
    handler["Class input/think handler"] -- "world.request(kind, payload, caller, activator)" --> buf["EntityWorld.requests"]
    buf --> take["Engine::take_entity_requests()"]
    take --> known{"host_requests::* ?"}
    known -- "script, script_call, script_file,<br/>play_sound, stop_sound,<br/>phys_wake, phys_sleep,<br/>ui_*, platform, changelevel, save" --> eng["Engine handles it"]
    known -- no --> game{"game.entity_request()?"}
    game -- no --> report["console: unknown host request"]

    classDef data fill:#2962FF,color:#fff
    classDef proc fill:#FF6D00,color:#fff
    class buf data
    class handler,take,eng,report proc

The list of engine-known requests is exactly host_requests in world.rs. Everything else is the game’s. This is what keeps the entity world a plain data structure and why the engine never links the game except in tests.

Snapshots: saving the world

crates/kerosene-entity/src/snapshot.rs writes an EntityWorld down as a WorldSnapshot and reads it back. It is what crates/kerosene-engine/src/save.rs puts in a .ksav. A snapshot holds:

  • every live slot’s entity: fields, connections, pose, next_think and brush model
  • every slot’s generation, and the free list in order
  • the I/O queue in firing order, with its sequence counter
  • the entity time

Four decisions keep it honest:

  • Handles survive. Each entity goes back into its own slot at its own generation. An EntityId held anywhere (a queued event, a script’s integer, the game’s state) names the same entity after a load, and the next spawn returns the handle it would have returned.
  • Spawn handlers do not run. Running them would re-fire logic_auto and reset counters. Classes that keep something outside their fields register ClassDef::on_restore instead. Only ambient_generic does, to ask for its looping sound again.
  • Restore is all or nothing. The engine restores into a fresh EntityWorld and swaps it in only on success. A damaged save leaves the running level alone.
  • Floats are made finite. JSON has no NaN, and one bad field should not make a game unsaveable.

The engine rebuilds everything else around the restored world the way a fresh load does, through the same Engine::load_level. Physics bodies come from the entities’ poses (PhysicsProps::adopt_props), with the saved velocities put back before anything steps.

tests/save.rs holds this to the strongest form available: 200 ticks played from a save must end in a snapshot equal to the 200 ticks played before it.

Triggers

crates/kerosene-engine/src/triggers.rs is an engine convention rather than game code: the engine looks for classes whose name begins trigger_ (compared in place, because to_lowercase per entity per tick was measurable) and reads well-known field names (push, teleport, damage, …). update_touch runs after movement, tests the player’s box against trigger volumes, and fires the class’s touch input. A game’s own classes get the same behaviour by following the field names, with no engine change.

Scripting: the layer above I/O

crates/kerosene-script/src/lib.rs. Entity outputs compose further than they have any right to, but some things are not a graph: counting, arithmetic, “pick one of three at random”, “only if the player still has the crowbar”. That is what scripting is for.

The design is a snapshot plus a queue, and the reason is stated in the crate doc: a script function outlives the call that registered it, so a live &mut EntityWorld borrow would have to be 'static — and even if it could be done, a script mutating the world halfway through a frame would observe a state no other code sees.

---
config:
  layout: elk
---
flowchart TB
    tick["Engine::tick → call_script_hook / run_script"] --> view["Engine::script_view()<br/>O(entities) snapshot"]
    view --> set["host.set_view(WorldView)<br/>entities, cvars, time, tick, map, player"]
    set --> run["ScriptHost::run / call"]
    run --> actions["Rc&lt;RefCell&lt;Shared&gt;&gt;.actions<br/>Vec&lt;ScriptAction&gt;"]
    actions --> apply["Engine::apply_script_actions()"]
    apply --> log["Log"]
    apply --> cmd["Command → console.enqueue"]
    apply --> fire["FireInput → entities.queue_input"]
    apply --> field["SetField / SetOrigin / Kill"]
    apply --> snd["PlaySound / StopAllSounds"]
    run -. "reads only" .-> view

    classDef proc fill:#FF6D00,color:#fff
    classDef data fill:#2962FF,color:#fff
    class view,actions data
    class tick,set,run,apply,log,cmd,fire,field,snd proc

ScriptAction is the entire surface area: the list is short and auditable, and adding to it is deliberate. A script cannot allocate a slot, walk the BSP tree, open a file or touch the renderer.

Bounds are set on the Rhai engine in ScriptHost::new: max_operations 2,000,000, max_call_levels 64, max_expr_depths 128/64, string/array caps, and a DummyModuleResolver so import cannot escape the bounds. MAX_ACTIONS is 4096, the script equivalent of MAX_EVENTS_PER_TICK.

Two details in ScriptHost::load matter:

  • only functions are merged into the persistent module (clone_functions_only), because a top-level print would otherwise re-run on every later call and every console line;
  • loading the same name twice replaces its definitions, so script_reload does what it looks like it does.

Hooks are looked up by name: on_map_start and on_tick are declared in kerosene_script::hooks. function_arity lets the engine hand a hook the caller only when the script declared a parameter, so fn on_use() and fn on_use(who) both work. Engine::load_map_script loads scripts/<map>.kscr when it exists; a map without one is silent.

Engine::apply_script_actions applies queued actions whether or not the script finished, because a script that fires a door and then throws has already fired the door as far as anyone watching is concerned. Command is enqueued rather than executed, so a script running inside a command cannot run more commands underneath it.

The id scripts see is kerosene_engine::scripting::pack — a slot index and generation packed into a u64 — and actions unpack it, so a handle held across a death cannot come back pointing at whatever took the slot.

Next: Rendering and streaming.

Rendering and streaming

kerosene-render splits cleanly in two: mesh.rs, lightmap.rs and camera.rs are pure CPU logic that decide what to draw and can be tested without a GPU; gpu.rs is the wgpu layer that draws it. The module doc in crates/kerosene-render/src/lib.rs states the frame in four steps: find the viewer’s cluster, take its PVS row, frustum-cull the leaves then their surfaces, draw what is left batched by material.

From BSP to triangles

WorldMesh::build_for(bsp, atlas, keep) in crates/kerosene-render/src/mesh.rs turns the compiled faces into WorldVertex { position, normal, uv, lightmap_uv, tangent } plus an index buffer. Two coordinate conversions happen here and nowhere else:

  • Winding order. Faces are stored clockwise seen from the front (the brush-file convention). GPUs treat counter-clockwise as front-facing, so the triangle fan is emitted reversed.
  • UVs. texinfo produces texels; shaders want them normalised by the texture size, so the atlas and material coordinates are divided at build time.

The tangent is read from the texture projection, not derived from the triangles. texinfo already stores the world direction u increases along, and that is the tangent; deriving it from geometry would agree only by coincidence and light a normal map from the wrong side wherever it did not. The bitangent’s sign lives in tangent.w, which is what stops mirrored text from lighting inside out.

Faces are grouped by material first and the groups are sorted, so batch order and the index buffer are byte-identical between runs. A Batch records contiguous_range when every surface in it is contiguous in the index buffer, which lets the whole world draw in as many calls as it has materials rather than one per face.

Two mappings are built after the surfaces:

  • leaf_surfaces: Vec<Vec<u32>> — surface indices per BSP leaf, for PVS culling.
  • model_surfaces: Vec<Vec<u32>> and model_bounds — surface indices per brush model. This exists because brush entities are compiled as their own models and their leaves are not in the world’s PVS. A leaf walk finds the world and nothing else. A comment in WorldMesh records the war story: every brush entity in every map was once built into the mesh and never drawn, and the sample map’s door was simply not there.

What to draw

---
config:
  layout: elk
---
flowchart TB
    eye(["viewer eye + frustum"]) --> cluster["bsp.point_cluster(eye)"]
    cluster --> pvs["bsp.visible_leaves(cluster)<br/>(all leaves if no vis)"]
    pvs --> leafloop{"for each leaf"}
    leafloop -- "leaf bounds vs frustum" --> leafsurf["leaf_surfaces[leaf]"]
    leafloop -- rejected --> leafloop
    leafsurf --> surfcheck{"surface bounds vs frustum"}
    surfcheck -- yes --> seen["dedupe via seen[]"]
    surfcheck -- no --> leafloop
    seen --> sort["sort by (material, surface)"]
    sort --> draw["Renderer::draw_world"]

    models(["brush models"]) --> modelvis["WorldMesh::model_is_visible<br/>frustum only, by posed bounds"]
    modelvis --> drawmodel["Renderer::draw_model per model"]
    props(["physics props"]) --> proppose["blend previous/current pose<br/>by interpolation_alpha"]
    proppose --> drawmodel

    classDef proc fill:#FF6D00,color:#fff
    classDef data fill:#2962FF,color:#fff
    class eye,models,props data
    class cluster,pvs,leafsurf,seen,sort,draw,modelvis,drawmodel,proppose proc

WorldMesh::visible_surfaces does the leaf walk: the PVS removes rooms you cannot see through any opening, the frustum removes what is behind you, and neither subsumes the other. It sorts the surviving surface indices by (material, surface) so gpu.rs’s draw loop rebinds a material only when it actually changes.

Brush models deliberately skip the PVS. model_is_visible culls by frustum against the posed bounds (the enclosing box of the turned box, because a frustum test wants an axis-aligned answer). The comment is explicit: a brush entity could be leaf-tested, but there are a handful of them, they are the things the player walks up to, and culling one wrongly costs far more than drawing it.

Interpolation

Engine::interpolated_brush_model_poses(alpha) blends each model’s previous and current (origin, angles) and rebuilds its Pose around the model’s own compiled centre. The centre comes from the model’s bounds rather than a keyvalue — a brush model is built in world coordinates, so there is no other point that means “spin where you stand”. Collision traces the raw current-tick pose; only the drawn pose lags by up to one tick, the same as the camera.

Physics props each take a model slot in the host’s draw and draw where their body is, blended from PhysicsProps::previous_pose. MAX_MODELS slots are uploaded as a single update_models uniform array with a dynamic offset — see gpu.rs, ModelUniform.

Lightmaps

crates/kerosene-render/src/lightmap.rs packs every face’s per-luxel grid into one 2048² atlas (ATLAS_SIZE) so the world draws without a texture bind per face. The packer is a shelf packer: sort by height, lay pages out in rows. It is not optimal, but lightmap patches are small and similar-sized, which is exactly what shelf packing handles well — and being deterministic matters more than the last few percent of occupancy, because a map that packs differently between runs invalidates every cached atlas.

PADDING = 1 blank texel around each patch, because bilinear filtering at a patch’s edge otherwise samples its neighbour and every surface picks up a thin bleed. AtlasRect::to_uv insets a half-texel so a sample at luxel 0 lands at the centre of the first texel. overflowed counts faces that did not fit; they draw unlit and the host warns, suggesting a coarser lightmap scale.

The atlas holds linear light in Rgb9e5Ufloat (ATLAS_FORMAT): three 9-bit mantissas and a shared exponent, four bytes a texel like the RGBA8 it replaced, and filterable everywhere. It used to be 8-bit, squeezed through a Reinhard curve on the CPU and then compressed a second time by the shader, so a lamp and the wall beside it read nearly alike. The codec is in kerosene_bsp::cubemaps (encode_rgb9e5), shared with the probes.

The atlas is built at unit exposure, and mat_exposure is applied live by the tone-map pass. Folding exposure into the atlas as well squared it and froze half of it at load time — a bug the host comment records.

A frame’s passes

---
config:
  layout: elk
---
flowchart LR
    scene["scene pass<br/>Rgba16Float HDR, MSAA x4<br/>world, brush models, props, lines"] --> resolve["resolve<br/>(end of pass)"]
    resolve --> tonemap["tonemap pass<br/>exposure + ACES / Reinhard / clip"]
    tonemap --> ui["UI pass<br/>egui, not tone-mapped"]
    ui --> present(["swapchain"])

    classDef pass fill:#FF6D00,color:#fff
    class scene,resolve,tonemap,ui pass

Every scene shader writes linear HDR into HDR_FORMAT and nothing tone-maps per surface. Renderer::begin_scene_pass returns the pass; with r_msaa above 1 it draws into a 4x target and resolves into the single-sample one, discarding the samples (the cheap path on tiled GPUs). A change of sample count rebuilds the scene pipelines, which is why set_msaa keeps the shader modules and layouts (SceneShaders). Renderer::tonemap then draws one fullscreen triangle: exposure, then the mat_tonemap curve. The UI draws over the result, so the console stays the colour it is. The host brackets the world, brush models, props and debug lines in push_debug_groups, so a RenderDoc capture reads as the frame’s structure.

Shading

world.wgsl and model.wgsl share one reflectance model – GGX, height-correlated Smith, Schlick – with metalness blending F0 from 4% grey to the base colour. crates/kerosene-render/src/brdf.rs is its CPU mirror, and its tests are the BRDF’s properties: the distribution projects to one, Fresnel runs from F0 to white, nothing reflects more than arrives. A change to one side that is not made to the other is a bug.

A lightmapped surface has no light direction – the bake says how much light arrived, not from where – so the world does not evaluate a GGX lobe at all. It uses the split-sum environment term (env_brdf, Karis’s fit) against what surrounds the surface: its cubemap probe, or, without one, an even glow the brightness of its own lightmap. Props do have a direction, their fixed key light, so they get the full lobe as well as the environment term.

Materials carry $metalness and $roughnessfactor as scalars beside the maps (MaterialUniform); a dielectric with neither a roughness map nor a factor below one takes no specular path at all, so an albedo-only material renders exactly as its lightmap says.

Skinning

Studio-model vertices go to the GPU with their four bone indices and weights (ModelVertex, the same 40 bytes as the file). A second dynamic-offset uniform holds bone palettes, kerosene_anim::MAX_BONES (128) matrices each: slot 0 is identities and never rewritten, so every static model binds it and draws as before; animated models get slots 1 to MAX_SKINNED from update_palettes. vs_model and vs_shadow_skinned blend the four matrices. Models have their own pipeline layout (SceneShaders::model_layout, the world’s plus the bones group), so world geometry is untouched.

kerosene-anim computes the palettes: Skeleton::of derives inverse bind matrices from the bones’ rest poses, sample interpolates a clip, and Playback crossfades a change of clip over CROSSFADE. A prop_dynamic keeps its playback in entity fields in game time; engine::animation reads them, fires OnAnimationDone when a one-shot ends and returns the prop to its defaultanim, and the host poses it at Engine::render_time(alpha) so it moves smoothly between ticks.

Static props

prop_static is drawn and collided with and never moves. The host groups them by model each frame and uploads one ModelInstance per copy (transform and probe) into a growable instance buffer; draw_studio_instances then draws every copy of a model in one call per mesh, through the vs_model_instanced entry point, which reads the transform from the instance buffer instead of the per-draw uniform. Shadows use the matching vs_shadow_instanced. A static prop’s probe is chosen once and cached. Physics gives each one a static Box3D box from its model bounds, and that box joins the ones the player’s hull sweeps against, so it blocks the player and thrown props without being part of the compiled world.

Dynamic lights and shadows

Everything baked stays baked; a light_dynamic (and the flashlight) is drawn live on top of it, which is Source 2’s split. crates/kerosene-render/src/lights.rs does the CPU half and is the reference for the shaders’ light loop:

  • Same numbers as a bake. Falloff and cone are kerosene_math::light, which Radiance now uses too, and intensity is on the lightmap’s scale (color * brightness / 255), so a live light and a baked one with the same _light read the same. A test pins that at 100 units.
  • Clustered. LightFrame::build keeps the nearest MAX_LIGHTS (32) whose spheres touch the frustum, and bins each into a 16 x 9 x 24 grid of tiles and exponential depth slices – one bit per light per cluster, 13.5 KiB of uniform. Binning is on the CPU: deterministic, and it works on GL, which has no compute. A fragment looks up its cluster and walks only its bits.
  • Shadows. A shadow-casting light gets layers of a 512² Depth32Float array, one for a spot and six for a point light, nearest first until the 16 run out; a light that misses out still lights, unshadowed. The host renders each layer before the scene with draw_world_shadow/draw_studio_shadow, culled by the light’s own PVS and frustum – visible_surfaces from the light’s position. Sampling is four hardware-PCF taps with a normal offset that grows with distance, plus slope-scaled depth bias in the pipeline.

The flashlight (flashlight, bound to F; cl_flashlight) is a spot held a few inches below and right of the eye, so its shadows are visible, with linear rather than physical falloff so it carries across a room. r_dynamic and r_shadows switch the two halves off; r_speeds reports lights and shadow views.

There are no dynamic shadows from the sun: light_environment is baked whole, and casting a prop’s shadow into it would need the bake to keep the sun’s share of each luxel separate.

Cubemap probes

Radiance bakes one per env_cubemap into the cubemaps lump (kerosene_bsp::cubemaps): six faces of RGB9E5, one ray a texel, each the lightmap where it landed times the surface’s reflectivity. At load, ProbeChain (probes.rs) builds a box-filtered mip chain down to one texel a face and GpuProbes uploads it as a 2D texture array, six layers a probe. Not a cube array: the GL backend lacks those, and the shader’s probe_uv picks the face itself from a table that must stay a copy of cubemaps::face_basis (a test holds the Rust side to its inverse). Roughness chooses the mip level.

Which probe a surface reflects is decided once, in WorldMesh::build_for: probe_for takes the nearest probe the face’s centre can see, falling back to the nearest at all, and writes it into every vertex (WorldVertex::probe, flat-interpolated), so batching by material is untouched. Props choose the same way from their origin each frame and pass it in ModelUniform::probe. Probes are map-wide rather than per section, since a probe is reflected by whatever can see it.

The wgpu layer

crates/kerosene-render/src/gpu.rs is deliberately thin: every what decision is already made by visible_surfaces, so this is buffer management, pipeline setup and a draw loop. Materials each get their own bind group; surfaces arrive sorted by material, so the loop rebinds only when the material changes.

CameraUniform carries view_proj, position, and a params vector of [unused, time, lightmaps_enabled, fullbright] plus bump/specular scales and the sky colour; the first slot was exposure, which moved to ToneMapUniform. The frame bind group is the camera, a section’s lightmap atlas and the map’s probes. ModelUniform is a full transform, not a displacement — the comment notes it was three floats until rotated brush entities showed up — plus the probe a studio model reflects. Props are drawn through draw_studio_model; debug overlays (prop boxes, streaming bounds, acoustic rooms) are LineVertex streams uploaded per frame.

crates/kerosene-render/src/camera.rs does the coordinate conversion once: Kerosene is Z-up +X-forward, graphics APIs want +X-right +Y-up −Z-forward. FOV is specified horizontally (REFERENCE_ASPECT = 4/3) because that is what a player judges, and is converted to the vertical angle the projection matrix wants, so a widescreen monitor shows more at the sides rather than stretching.

Streaming

crates/kerosene-engine/src/streaming.rs decides which sections are resident. It only decides; the host builds and drops GPU data and reports back with Streaming::mark_loaded, and physics syncs hulls on a state change.

---
config:
  layout: elk
---
stateDiagram-v2
    direction LR
    [*] --> Unloaded
    Unloaded --> Wanted: section has a face in the PAS of the player's cluster
    Unloaded --> Wanted: awake physics prop inside it
    Wanted --> Loaded: host calls mark_loaded()
    Loaded --> Loaded: linger timer while still wanted
    Loaded --> Unloaded: unwanted for sv_stream_linger seconds
    Wanted --> Unloaded: unwanted before the build finishes

The rule is potential audibility: a section is wanted when any of its faces sits in a cluster the player’s cluster can hear — the PAS, the PVS flooded one doorway further. A room therefore starts loading a doorway before it can be seen, and that doorway is the margin that hides the build. A section that stops being wanted lingers sv_stream_linger seconds so pacing over a threshold does not thrash. With no vis data, with the player outside the world, or with sv_stream 0, everything is wanted.

What streams is only the expensive part: the render mesh, the lightmap atlas and the rigid-body hulls. The BSP tree, the entity list, the player’s traces and entity I/O are whole whatever is loaded — the tree is small, traces must work everywhere, and a logic_relay in an unloaded room still fires. Brush entities are never streamed: a door is drawn and collided with wherever it has moved to.

The host side is App::stream_sections: it queues a job on the engine’s streaming workers (Engine::spawn_job, a bounded pool owned by the lifecycle manager) per wanted, not-yet-resident section to build WorldMesh + LightmapAtlas from the shared Arc<Bsp>, and uploads on the main thread when the mpsc receiver delivers. mark_loaded ignores a build that finishes after the section was dropped again, so a stale build cannot resurrect it. r_stream_debug 1 draws each section’s bounds in the colour of its state (section_debug_lines in host.rs).

This is sections of one map, not an open world: everything is still one .kbsp, compiled and lit as one.

Next: Physics.

Physics

Kerosene has two physics systems, and keeping them apart is a design decision, not an accident:

  • kerosene-physics is a faithful reimplementation of Source’s gamemovement. The way a Source game feels is this code — the acceleration model, the air-speed cap, the stair-stepping, the sliding solver. It stays.
  • kerosene-rigid wraps box3d-rust for everything the player is not: props that tumble, roll and settle.

crates/kerosene-engine/src/physics.rs joins them. PhysicsProps owns one PhysicsWorld and the entity↔body mapping; the engine calls it once per tick.

Player movement

crates/kerosene-physics/src/movement.rs, function player_move, in order:

---
config:
  layout: elk
---
flowchart TB
    entry(["player_move(state, input, params, world, dt)"]) --> noclip{"noclip?"}
    noclip -- yes --> fly["velocity = wish * max_speed * 4<br/>origin += velocity * dt"]
    noclip -- no --> env["update_water_level + update_ladder"]
    env --> record["record entry_fall_speed<br/>was_airborne = !on_ground"]
    record --> cat["categorize_position: standing on something?"]
    cat --> duck["apply_duck"]
    duck --> jump{"jump &amp;&amp; on_ground<br/>&amp;&amp; !jump_held &amp;&amp; not eyes-deep?"}
    jump -- yes --> jmp["velocity.z = jump_impulse<br/>on_ground = false"]
    jump -- no --> ladder{"on_ladder?"}
    jmp --> ladder
    ladder -- yes --> climb["ladder_move; return"]
    ladder -- no --> water{"water_level &gt;= Waist<br/>&amp;&amp; !on_ground?"}
    water -- yes --> swim["water_move; return"]
    water -- no --> ground{"on_ground?"}
    ground -- yes --> fric["apply_friction"]
    fric --> wish1["wish_direction"]
    wish1 --> acc["accelerate"]
    acc --> step["step_move (step up / move / drop)"]
    step --> stay["stay_on_ground"]
    ground -- no --> wish2["wish_direction"]
    wish2 --> air["air_accelerate"]
    air --> grav["velocity.z -= gravity * dt"]
    grav --> trym["try_move"]

    classDef proc fill:#FF6D00,color:#fff
    classDef dec fill:#AA00FF,color:#fff
    class entry,fly,env,record,cat,duck,jmp,climb,swim,fric,wish1,acc,step,stay,wish2,air,grav,trym proc
    class noclip,jump,ladder,water,ground dec

Three ordering details are load-bearing:

  • Friction runs before acceleration on the ground. Running it after makes ground movement mushy and breaks air strafing — the reason is in architecture.md.
  • Jumping is checked before friction, so the jump takes full ground speed with it rather than a decelerated version.
  • On the ground, velocity.z is zeroed and the move is step_move; in the air, gravity is applied and the move is plain try_move.

Why air control works

air_accelerate caps the wish speed it considers at MoveParams::air_speed_cap (30 units/s), not the resulting speed. So the “have I got enough speed already” test is against 30 rather than the run speed, and a player already moving at 400 can still gain speed by steering sideways. That single clamp is the origin of bunny-hopping and surfing; it is preserved deliberately. The code comment notes the rate still scales with the uncapped wish speed while the ceiling stays at the cap.

Stair stepping

step_move makes two attempts and picks whichever covered more horizontal ground:

  1. move straight along the ground (try_move);
  2. step up step_size (18 units — why Source staircases have 8-unit risers, two per step), move, then drop back down.

If the drop lands on something steeper than MAX_STANDABLE_Z (0.7 ≈ 45.57°), the attempt was a wall, not a step. The comparison is strict so a tie goes to the stepped-up attempt — otherwise sliding to a stop against a step covers exactly as much ground as stepping onto it and stairs can never be climbed. A clean step takes the downward velocity so walking up stairs does not launch you, and is not reported as a wall hit.

Hulls and water

STANDING_HULL is 32 wide × 72 tall with a 64-unit eye height; DUCKED_HULL is the same width at half height. MAX_STANDABLE_Z = 0.7 is cos(45.57°), Source’s value: the single number that decides which ramps are stairs and which are slides.

WaterLevel is Dry | Feet | Waist | Eyes. At Waist and above, player_move hands off to water_move (Quake’s water move): drag scales with depth, the wish follows the view’s pitch, and with no input the player sinks slowly rather than hanging in place. Ladders are found by point_contents_brushes with MASK_VOLUMES, not by a trace, because water and ladders are non-solid.

The CollisionWorld trait

crates/kerosene-physics/src/world.rs defines what movement needs:

#![allow(unused)]
fn main() {
pub trait CollisionWorld {
    fn trace_hull(&self, start, end, mins, maxs, mask) -> Trace;
    fn contents_at(&self, point: Vec3) -> u32;
}
}

BspWorld is the real one; BoxWorld is a hand-built floor/step/wall used by the tests. The trait exists so movement can be tested without a compiled map: testing against a map means debugging the map when a test fails.

crates/kerosene-engine/src/collision.rs adds two more worlds. LevelCollision traces the world BSP and every brush entity’s model separately, taking the nearest hit — a door is kept out of the world tree so it can move without re-splitting it. PlayerCollision additionally traces the rigid prop boxes, so the player collides with physics props. Both are rebuilt each tick (the comment in Engine::tick says why: a door that moved must block where it is now, and so must a prop the player just kicked).

Rigid-body props

crates/kerosene-engine/src/physics.rs. PhysicsProps maps entities to bodies:

---
config:
  layout: elk
---
flowchart TB
    load(["Engine::load_map"]) --> build["PhysicsProps::build_static_world(bsp, entities)"]
    build --> world["solid world brushes → static convex hulls (section 0)"]
    build --> detail["detail → static hulls too"]
    build --> move["contents::MOVEABLE brushes → static bodies teleported to entity pose"]
    build --> skip["triggers/water/ladders/clips skipped"]
    tick(["Engine::tick"]) --> newsync["sync_player(origin, hull, velocity)<br/>kinematic body"]
    newsync --> push["push_props(player Aabb, requested dir, force, speed)"]
    push --> held["steer_prop if carrying"]
    held --> step["sync_and_step(dt, entities, vfs)"]
    step --> newprop["new prop_physics → dynamic box from model bounds"]
    step --> simulate["PhysicsWorld::step"]
    step --> writeback["write body pose back to entity + previous map"]

    classDef proc fill:#FF6D00,color:#fff
    class build,world,detail,move,skip,newsync,push,held,step,newprop,simulate,writeback proc

Key decisions, each stated in the source comments:

  • Units are native. kerosene-rigid::init sets Box3D’s length-unit scale to INCHES_PER_METRE once per process; after that every vector is inches, Z-up, with no conversion anywhere. GRAVITY = (0, 0, -800).
  • Static world brushes become convex hulls so props have something to land on. Detail brushes are static geometry already in bsp.brushes, so they become hulls too. Moving brushes (contents::MOVEABLE) become static bodies that are re-placed to their entity’s pose every tick, so a closed door blocks a thrown prop and an open one lets it through. Triggers, water, ladders and player/monster clips are skipped entirely.
  • Each prop gets a dynamic box shaped from its model’s bounds, centred on the bounds’ centre so a model built off-centre still sits where it draws. load_model fails are cached so a missing .kmdl is warned about once.
  • The player is kinematic, not dynamic. The player’s own movement code is the authority on where they are; a dynamic body would be shoved around by the very props it is meant to shove. Without a body at all the player was a hole in the simulation — props fell through them and walking into a crate did nothing.
  • Solidity and pushing are separate. sync_player makes props bounce off the player; push_props lets the player move them. player_push_direction reads the requested direction, not velocity, because a crate stops the player dead and reading velocity would mean the harder you pressed the less you pushed.
  • Carrying. The use key grabs a prop; Engine steers it toward a point in front of the eye, traced against the world so the hold point does not land inside a wall, with the prop’s own half-extent kept clear of the surface. Yaw follows the player so the grabbed face keeps facing them; pitch and roll are preserved so a prop on its side stays on its side. Attack throws it.

PhysicsProps::previous_pose records each prop’s pose before the step so the renderer can interpolate a tumbling crate instead of snapping it every 1/64 s. debug_lines feeds the phys_debug wireframe overlay.

Next: Audio and acoustics.

Audio and acoustics

Sound splits into six separable layers in kerosene-audio, and a seventh (kerosene-engine/src/audio.rs) that decides when. The split is the design: everything that decides how a sound sounds — falloff, panning, resampling, voice limits — is arithmetic on buffers with no hardware in it, so a sound that pans the wrong way is a numeric fact rather than something you notice by ear on the third playthrough.

LayerSourceResponsibility
wavwav.rsSource .wav → samples
compiled + adpcmcompiled.rs, adpcm.rs.kaud → samples (4 bits/sample)
mixermixer.rsVoices → stereo buffer, no device
reverb, env, dspreverb.rs, env.rs, dsp.rsThe room and the air
devicedevice.rs (feature device)Buffer → sound card
enginecrates/kerosene-engine/src/audio.rsBank, listener, voice tracking
acousticscrates/kerosene-engine/src/acoustics.rsRoom lookup, occlusion

The mixer runs whether or not a sound card does. If audio only existed when a device opened, everything about a game’s behaviour that touches sound — how many voices a trigger starts, whether a looping ambience got stopped — would differ between a machine with sound and one without, and only one of those would ever be tested. A missing device costs the last hop to the speakers and nothing else (AudioSystem::silent).

From a name to samples

kerosene-audio::SoundBank turns "door/open" into samples through a sound script (*.ksnd, parsed by script.rs). AudioSystem::sound tries every form the name might be, not one guessed path — guessing was what reported sound/ambient/track.wav missing when the file on disk was a .flac, a path nobody had written about a file that was right there. The engine prefers .kaud and falls back to .wav, so a designer who just dropped a file in hears it without running a build, and a shipped game carries only the small one. A missing name is warned about once (mark_missing), because a trigger firing every tick would otherwise fill the console.

The mixer

crates/kerosene-audio/src/mixer.rs is pure arithmetic. The model is Source’s, because it is the one designers already reason about: a sound is placed in the world, gets quieter with distance according to its own attenuation, and is panned by where it is relative to the way you are facing. Sounds with no position are heard flat.

SoundParams carries position, volume, pitch, attenuation, reference_distance and max_distance. Listener { position, basis } is set each tick by the engine (Engine::tick → audio.set_listener), so a headless run mixes the same audio a windowed one does.

Reverb: a feedback delay network

crates/kerosene-audio/src/reverb.rs. A real room is a sound bouncing between its walls, losing a little at each one and more of the highs than the lows. The model is exactly that with eight paths: eight delay lines, a matrix scattering each output into all the others, and a filter in each loop that removes per band what the walls would. Every knob is a physical quantity, so the compiler can fill it in from geometry and a designer can read it.

ReverbParams is the description; Fdn is the thing that runs. BANDS_HZ is [125, 500, 2000, 8000] — four bands, two octaves apart; materials are tabulated this way, air is modelled this way, and the loop filter can hold exactly this many independent gains. The network is one per mixer and slides between rooms rather than jumping, because a jump is a click.

The engine’s side

---
config:
  layout: elk
---
flowchart TB
    tick(["Engine::tick"]) --> listener["audio.set_listener(eye, basis)<br/>audio.set_volume(volume)"]
    listener --> acoustics["update_acoustics()"]
    acoustics --> room["bsp.point_leaf(eye) → room_at()<br/>blend near a boundary"]
    room --> mix_rev["mixer.set_reverb(params)"]
    acoustics --> reachfn["reach(bsp, eye, basis, source)<br/>PAS test + 3 lines"]
    reachfn --> voices["AudioSystem::update_voices(eye, room_wet, air, reach)"]
    voices --> env["VoiceEnv per voice:<br/>air cutoff, occlusion cutoff/gain, reverb send"]
    env --> mixer[("Mixer")]

    classDef proc fill:#FF6D00,color:#fff
    classDef data fill:#2962FF,color:#fff
    class tick,listener,acoustics,room,reachfn,voices,env proc
    class mix_rev,mixer data

Room lookup (crates/kerosene-engine/src/acoustics.rs)

surroundings(bsp, eye) is a leaf lookup and a copy, plus one refinement: near a boundary between two rooms the two are blended by how close the boundary is (BLEND_DISTANCE = 64), so stepping through a doorway is a slide rather than a switch. The mixer smooths on top of that, but its smoothing is in time and this is in space — standing still in a doorway should sound like a doorway. Decay times blend geometrically (blend), the way the ear hears them. A forced snd_reverb_preset wins over the map so a designer can audition a hall without compiling one; a map with no acoustics is dry.

Occlusion (reach)

reach(bsp, eye, basis, source) -> Reach { Clear | Occluded(f32) | Unreachable } asks three questions, cheapest first:

  1. Is the source’s cluster in the listener’s PAS — the PVS grown by one room? If not, no path exists and the sound is not heard. This is the one runtime use of the PAS; streaming uses it too, but for loading.
  2. If the source is inside rock, Unreachable (but an ear inside rock still hears everything rather than nothing).
  3. Otherwise, trace three rays from the ear to the source — straight, and to each side (basis.right * 40). A wall blocks all three; a doorway lets one or two through, so the sound comes round it thinned rather than cut.

Being in the same room halves the result: a pillar between two people in a hall is not a wall.

Voices

AudioSystem tracks every positioned voice (TrackedVoice) and shapes it each tick in update_voices. Occlusion traces are the expensive part, so at most OCCLUSION_BUDGET = 24 voices are re-traced per tick round-robin; the rest keep last tick’s answer, because walls do not move much in 1/64 s. The result is one VoiceEnv per voice (low-pass cutoff, gain, reverb send), pushed to the mixer through a shared MixerControl without taking the mixer’s lock for the listener. The buffers trade places with the mixer’s rather than being rebuilt, so steady state allocates nothing.

SF_EVERYWHERE = 2 is a spawnflag on a sound entity: heard flat, wherever the listener is. It is an engine convention, not game code, so any game’s sound class gets it by following the field name; the stock ambient_generic does. There is a test in crates/kerosene/src/lib.rs asserting the engine’s and the game’s constants agree.

Debug

snd_acoustics_debug 2 draws every leaf near the eye boxed in its room’s colour (blue dead, red live) and a line from the eye to every followed voice, green where it gets through and red where it does not (crates/kerosene-engine/src/acoustics.rs::debug_lines). AudioSystem::room holds the last room for the readout.

Next: The game UI.

The game UI

How the UI is built, for someone changing it. For how to use it, see Game UI. When a note and the code disagree, the code wins.

Where it lives

crates/kerosene-uiEverything up to a list of quads. No GPU, no winit, no engine
crates/kerosene-render/src/ui.rs, shaders/ui.wgsl, shaders/panel.wgslDrawing those quads, on screen and into world panel textures
crates/kerosene-render/src/decals.rsCutting decals out of the world mesh (CPU)
crates/kerosene-render/src/gpu.rs (GpuDecals, Pass::Decal)Drawing them
crates/kerosene-engine/src/ui.rsThe engine’s side: the store, publishing, console commands, world panel entities, the decal list
crates/kerosene-engine/src/host.rsInput routing, the menu’s hold on the mouse, the frame’s UI passes
crates/kerosene-game/src/ui.rslogic_ui, point_worldpanel, infodecal
crates/kerosene-game/src/weapons.rs, crates/kerosene/src/lib.rs (Stock)The weapon stub that feeds the HUD
crates/kerosene-tooluiNot this. The tools’ egui host, which used to own the name

kerosene-ui, in order

flowchart LR
    xml[".kui"] --> markup["markup.rs<br/>escape, parse"] --> doc
    css[".kcss"] --> cssrs["css.rs<br/>rules, selectors"] --> doc
    rhai[".kscr"] --> script["script.rs<br/>sandboxed VM"] --> doc
    store["store.rs<br/>UiStore"] --> bind["bind.rs<br/>templates, deps"] --> doc
    doc["document.rs<br/>Document::update"] --> style["style.rs<br/>cascade"]
    doc --> layout["taffy<br/>flexbox"]
    doc --> text["text.rs<br/>ab_glyph, atlas"]
    doc --> draw["draw.rs<br/>DisplayList"]
    system["system.rs<br/>UiSystem: layers, panels, hot reload"] --> doc

Document::update runs the same steps every frame:

  1. Evaluate the bindings whose store keys changed since the last frame (UiStore::changed_since, Template::depends_on).
  2. Deliver the store’s events to on: handlers and on_event.
  3. Run due timers, then the handlers input queued.
  4. Apply what scripts asked for (DocOps, store writes, UiActions).
  5. Re-cascade every panel marked dirty, with its subtree.
  6. Advance transitions and keyframe animations into Node::shown.
  7. Lay out with taffy. It caches, so an unchanged tree costs almost nothing.
  8. Rebuild the display list, recording each panel’s drawn rect and inverse transform for hit testing.

Input (pointer_move, pointer_button, key, text) never runs script. It changes state and queues Pending handlers, which step 3 runs with the store in scope. So a click’s handler runs on the next update, and input handling needs no access to the store.

Decisions worth knowing

  • Scripts never touch a live structure. Like level scripts, UI script calls queue DocOps and UiActions and are applied after the script returns (script.rs).
  • Bindings are Rhai expressions compiled once, with store roots pushed into the scope as nested maps (UiStore::to_scope_maps). Every root a binding names is pushed, as an empty map if nothing has been published under it, so an unpublished key reads (). Dependencies come from a textual scan (bind::dependencies), which is conservative: a false positive only costs an extra evaluation.
  • Markup is preprocessed (markup::escape_code, declare_prefixes): < and & inside attribute values and <script>/<style> are escaped, -- in comments is neutralised, and the class:/style:/on: prefixes are declared as XML namespaces on <root>. Single-quoted strings in code become double-quoted (bind::single_quotes_to_double). Each of these came from a sample layout failing to load.
  • Units. Layout happens in UI pixels (reference height 1080, or reference-height). The display list is in physical pixels. Glyphs are rasterised at physical size, keyed by size in quarter pixels.
  • <Repeat> has no layout box. Its children are attached to the nearest non-Repeat ancestor’s taffy node (layout_parent, sync_layout_children), and read_layout passes the parent’s origin through it.
  • Transitions snapshot shown when a property’s target changes and blend from that snapshot to the new target. Only the properties in style::AnimProp animate. Keyframe animations hold their last frame (forwards).
  • Hot reload reads every source a document loaded once a second and compares FNV hashes. It works through the VFS, so it also covers files in archives. A document that fails to reload keeps the old one.

Rendering

pack turns a display list into GpuQuad instances and batches, splitting on image, blend mode and scissor. Solid and glyph quads can join any batch, since the image binding is unused for them. A quad whose image hasn’t loaded is dropped before batching. Otherwise one missing image took every quad in its batch down with it, which is how a missing logo once blanked a whole menu.

The shader converts authored sRGB colours to linear, blends premultiplied in linear, and lets the sRGB target encode. If the target isn’t sRGB, output_srgb encodes in the shader instead. Both textures are sampled before any branch or discard, to keep naga’s uniformity analysis happy.

Frame order in host.rs:

  1. ui_frame runs the documents. This happens in App::frame, after the ticks.
  2. Upload the glyph atlas and any new images.
  3. Render world panels into their textures, but only those whose revision moved.
  4. Cut new decals and upload them if the decal revision moved.
  5. Shadows, then the scene pass. Decals are drawn per section after that section’s world, with the section’s frame bind group, because each section has its own lightmap atlas. World panels are drawn after the props.
  6. Tonemap, then the screen UI, then egui (Game::ui and the console).

Decals

decals::build clips each world-model triangle inside the decal’s oriented box (Sutherland–Hodgman against six planes), keeps its lightmap UVs, replaces the UVs with the decal’s projection, and lifts the result decals::LIFT units off the surface. Facing is judged by vertex normals, not winding. The host caches cut geometry by DecalRequest::id and clears the cache when the map generation changes. The engine keeps only the request list, so headless runs agree with windowed ones about what was placed.

World panels

Engine::sync_world_panels rebuilds the panel list from point_worldpanel entities every UI frame. The panel faces along the entity’s forward vector, and its right edge is -basis.right, the viewer’s right. world_panel_input runs in the tick before use and attack are handled: it ray-casts the view against interactive panels within 1.5 × sv_use_range, checks the world isn’t in the way, and turns presses into pointer events. When it takes a press, the tick does not also use or throw. Engine::aiming_at_panel lets a game hold its fire too, and the stock game does.

Tests

  • crates/kerosene-ui/src/*/tests.rs cover each module headless.
  • crates/kerosene-ui/tests/content.rs loads every shipped layout and fails on any warning.
  • crates/kerosene-engine/tests/ui.rs uses a compiled map to test map scripts publishing, damage events, infodecal, the decal command, the pause menu, and a keypad panel (a test fixture of its own) whose OnUnlock opens a door. The demo’s real keypad is tested in kerosene-demo’s tests/keypad.rs.
  • crates/kerosene-render/tests/ui_smoke.rs runs on a real GPU and skips without one. Set KEROSENE_UI_SHOT=<dir> to get PNGs of the shipped HUD and pause menu.

Next: The store (Steam).

The store (Steam)

How the Steam integration is put together, for someone changing it. For using it, see Steam. When a note and the code disagree, the code wins.

Where it lives

crates/kerosene-platform/src/lib.rsPlatform: declarations, validation, stat batching, the event queue. PlatformAction and its one-line text form, PlatformEvent, PlatformView
crates/kerosene-platform/src/null.rsThe offline stand-in. It behaves like a store: it simulates leaderboard ranks, and keeps “cloud” files in memory or a directory
crates/kerosene-platform/src/steam.rsSteamBackend, behind the steam feature
crates/kerosene-script/src/platform.rsThe Rhai platform/steam object, registered by both script VMs
crates/kerosene-engine/src/platform.rsEntity requests, result outputs, console commands, publishing to the UI store
crates/kerosene-game/src/platform.rsThe five logic_* classes
tools/kiln/src/steam.rs--ship --steam: build options, finding the redistributable, SteamPipe scripts
tools/kerosene-tools/src/workshop.rsworkshop upload

One path in, one path out

Everything becomes a PlatformAction and goes through Platform::apply:

  • entity requests (host_requests::PLATFORM, whose payload is the action’s text form)
  • ScriptAction::Platform and UiAction::Platform
  • console commands
  • Engine::platform_apply

apply checks declared ids, turns Progress at its maximum into Unlock, rounds integer stats, and queues whatever events it can report synchronously. Asynchronous results, such as leaderboards and the overlay, arrive through Backend::frame.

Engine::dispatch_platform_events then hands each event to four listeners, in order:

  1. Entities. Every entity of the matching class that names the same id, stat or board, not only the one that asked, so nothing has to remember a caller across an asynchronous call.
  2. The UI store. The platform.* keys are republished and the event is emitted.
  3. The map script’s on_platform_event.
  4. Game::platform_event.

A handler may cause more events; for example, a stat threshold unlocks an achievement. Dispatch loops, capped at 16 rounds.

Output parameters: in this engine an output’s value replaces a wire’s own parameter. So only the outputs that mean something by it (OnChanged, OnRankImproved, OnSubmitted, OnDlc*) pass one. OnUnlocked, OnAvailable and OnThreshold keep the wire’s.

Timing

  • Engine::tick dispatches what the tick caused.
  • Engine::platform_frame pumps the backend and dispatches. The host calls it every frame, before the UI, so Steam’s callbacks keep flowing while the game is paused under the overlay. Headless runs call it every tick.

Startup

Platform::new runs inside Engine::with_game, before the VFS is sealed, because subscribed Workshop items are more archives to mount. launch::config_from reads the store settings from the project whichever way the content was found. It sets use_steam only for a windowed run without --no-steam. launch calls restart_through_steam before anything else, which does nothing in debug builds or with steam_appid.txt beside the executable.

The Rhai object

platform is resolved through Engine::on_var, not pushed into a scope. A Rhai function cannot see its script’s top-level scope, and the object has to work inside fn award() { ... }. The resolver runs first, which is why the names are reserved. A VM can have only one resolver, and nothing else in either VM sets one.

Tests

  • crates/kerosene-platform/src/tests.rs covers Platform against the null backend and the Rhai object.
  • crates/kerosene-engine/tests/platform.rs uses a compiled map to cover every class’s outputs, map scripts, and cheat gating.
  • tools/kiln/src/ship/tests.rs covers --steam ships against a fake build directory.
  • CI lints the steam feature and runs the platform tests with it on. The vendored library links without a Steam client.

Next: Tools and the build.

Tools and the build

The toolset is one executable that contains ten tools, plus kiln, which drives the others. This page is how a project is found, how the tools find each other, and how a distribution is assembled safely.

The boundary

---
config:
  layout: elk
---
flowchart LR
    subgraph tools["kerosene-tools (one binary)"]
        gui["Toolset window<br/>project, editor, sound, build, archive, models"]
        subs["headless subcommands<br/>init, cleave, umbra, resonance, radiance,<br/>alchemy, forge, timbre, kiln, vault"]
    end
    runtime["kerosene runtime"]
    archive["content.vault"]
    tools --> runtime
    gui -. "drives" .-> subs
    runtime --> archive
    subs --> archive

    classDef tool fill:#FF6D00,color:#fff
    classDef out fill:#2962FF,color:#fff
    class gui,subs tool
    class runtime,archive out

crates/kerosene-vfs/src/toolchain.rs is the practical definition. A subcommand is always present because it is compiled into the executable and run by re-invoking that same executable with the subcommand as its first argument (toolchain::command). This preserves the old crash-isolation property: a compiler that fails does not take the editor down with it. The runtime is a sibling binary first and on PATH second, because a checkout and an install both put the two in one directory.

tools/kerosene-tools/src/lib.rs lists the subcommands in one SUBCOMMANDS array and dispatches them in run_subcommand. tools/kerosene-tools/src/entry.rs turns the binary into a function, main_with(Options), so a game can ship its own tools that know its classes:

const SCHEMA: &str = include_str!("../content/mygame.kdef");
fn main() -> anyhow::Result<()> {
    kerosene_tools::main_with(
        kerosene_tools::Options::new("mygame-tools", env!("CARGO_PKG_VERSION"))
            .schema(&[SCHEMA])
            .game("mygame"),
    )
}

Options::runtime() returns a Runtime::Package built from the working directory, so F9 in the editor and play build and launch the game rather than a binary called kerosene.

Two subcommands exist for game crates rather than for content:

  • new (src/new.rs) writes a game crate from the templates in src/new/template/: a package, its toolset binary, cargo aliases, a project file and a starter map built with kerosene-map. It depends on the Kerosene the toolset was built from: the checkout’s path, the release tag when installed from git, or the crates.io version.
  • play (src/play.rs) runs Kiln’s content stages incrementally, with the maps’ expensive passes skipped unless --full, then toolchain::resolve to build and run the game.

Kiln decides what is current by file times: an output no older than its source is skipped, a map also needs a .kbuild stamp saying it was built at least as thoroughly as asked (fast or full), and the archive is skipped when nothing it would pack is newer than it. --force rebuilds everything.

Finding the content

crates/kerosene-vfs/src/root.rs. Every tool needs the same answer to “where is the content”, and when each worked it out its own way they disagreed — and disagreeing about a directory looks, from the outside, like every one of them being broken in a different way.

---
config:
  layout: elk
---
flowchart TB
    start(["find()"]) --> explicit{"--content given?"}
    explicit -- yes --> verify{"is_content_root?"}
    explicit -- no --> project{"Project file found<br/>by climbing?"}
    project -- yes --> useP["use project.content"]
    project -- no --> cwd{"cwd is a content root?"}
    cwd -- yes --> useC["use cwd, why = ..."]
    cwd -- no --> climb["climb up to MAX_CLIMB=6 levels<br/>looking for kerosene.kdef<br/>or maps/ + materials/"]
    climb --> found
    verify --> found["Found { root, why, project }"]
    useP --> found
    useC --> found
    found --> scaffold["root::scaffold(dirs)<br/>create missing CONTENT_DIRS"]

    classDef dec fill:#AA00FF,color:#fff
    classDef proc fill:#FF6D00,color:#fff
    class explicit,project,cwd,climb,verify dec
    class found,scaffold,useP,useC proc

is_content_root accepts a kerosene.kdef file as the strong signal, or a directory with both maps and materials — a project that has not written its class definitions yet is still a project. Found.why records which rule fired, so a wrong guess explains itself; a wrong guess that explains itself costs a minute and a silent one costs an afternoon.

CONTENT_DIRS = ["maps", "materials", "art", "textures", "models", "sound", "scripts"] and scaffold creates any that are missing. A project can overrule the list with repeated "dir" keys (Project::dirs), and those names go through the same path::normalize the VFS uses, so .. or a drive letter is refused.

The project file

crates/kerosene-vfs/src/project.rs. .kproj is a project’s own account of where its content is. Everything before it infers the root by climbing the tree, which works for a fresh clone but is a guess that can be wrong in ways nobody can correct. A project file sits at the top, names the content directory relative to itself, and every tool that finds it stops guessing.

KeyMeaning
nametitle-bar name; defaults to the file stem
contentcontent tree, relative to the file; falls back to content/ or the file’s directory
startmapmap to load when nothing else says which (optional)
gamethe Cargo package that is the game; kiln --ship builds it and F9 launches it
binthat package’s binary, when not the package’s own name
dirrepeatable; overrides CONTENT_DIRS

Everything but the block itself is optional. A content-only project has no game and runs and ships the engine’s own runtime instead.

The VFS

crates/kerosene-vfs/src/lib.rs is a stack of search paths forming one virtual content tree. Each SearchPath is a directory or a mounted .vault plus an id (GAME, MOD, PLATFORM). Lookups are case- and separator-insensitive (Materials\Dev\Grid.kmat finds materials/dev/grid.kmat), which a loose tree has to agree on or a game works from a checkout and breaks the moment it is packed. Traversal cannot escape the root (path::normalize).

A read_normalized that hits a permissions error surfaces it rather than falling through to a stale copy in a lower layer. Writes land in the first directory layer; archives are read-only by design, because mutating a mounted archive under a running engine would invalidate every offset it has cached. write_atomic in the same file writes to a sibling temp and renames, so a crash mid-save cannot truncate a map someone spent a day on.

The .vault format

crates/kerosene-vfs/src/archive.rs. MAGIC = "KVLT", version 1, header 40 bytes, then a directory of entry_count records sorted by path, then a data blob:

[ header 40 bytes ]
[ directory: path_len:u16, path, crc32:u32, offset:u64, size:u64 ]
[ data blob ]

Paths are stored normalised, so lookup is a binary search with no per-query allocation. Every read is checked against the stored CRC32: a corrupt archive is a common failure (truncated download, bad copy) and is far better caught here than as a garbled texture three subsystems later. Loose files win over packed ones, which is the mod-development workflow — drop a file next to a shipped archive and it takes effect with no repack.

Kiln

tools/kiln/src/lib.rs, build(settings). Stages run in a fixed order; --only names one for iteration.

---
config:
  layout: elk
---
flowchart LR
    T["Textures<br/>alchemy (library)"] --> S["Sounds<br/>timbre (library)"]
    S --> M["Models<br/>forge subprocess"]
    M --> MP["Maps<br/>cleave → umbra → resonance → radiance"]
    MP --> P["Pack<br/>vault"]
    P --> SH["Ship (optional, --ship DIR)"]
    MP -. "leak?" .-> skip["report the leak,<br/>skip vis/acoustics/lighting"]
    skip -.-> P

    classDef stage fill:#FF6D00,color:#fff
    classDef warn fill:#D50000,color:#fff
    class T,S,M,MP,P,SH stage
    class skip warn
  • Textures and Sounds are library calls, not subprocesses. The reason is in tools/kiln/src/lib.rs and tools/alchemy/src/lib.rs: Chisel and Timbre’s own GUI make the same calls, and two callers of one step must not be able to disagree.
  • Models: art/props/crate.obj becomes models/props/crate.kmdl — the path under art is the path under models, so a model’s name is decided by where its source is.
  • Maps: each .kmap goes through all four compilers. A leak is reported rather than fatal to the whole build (finding out at the end of a forty-map build beats finding out on the first), and the remaining stages are skipped unless --ignore-leaks.
  • Pack: vault packs the content tree into the project’s archive (Settings::archive, inside the content tree where a shipped game keeps it).
  • Ship is deliberately not in Stage::ALL: building content is something done every few minutes, assembling a distribution is not.

Settings mirrors the CLI and Report collects counts for the toolset’s output panel. --fast passes through to Umbra and Radiance; --dry-run says what would run.

Ship

tools/kiln/src/ship.rs. A game is an executable, an archive, a project file telling the executable where the archive is, and the notices the licences require, arranged so double-clicking works:

dist/
  my_game            the game, or the engine runtime when a project has none
  my_game.kproj   content = "content", so the game finds its own archive
  content/
    my_game.vault
  LICENSE            GPLv3 full text
  LICENSE-EXCEPTION  the Kerosene Exception, full text
  README.txt         the notices the licence asks for

The doc in ship.rs names the two opposite failure modes. Shipping too little is the common one (forgotten licence texts, because nothing breaks), so they are written by code, compiled into kiln so shipping works from a directory that is not a checkout. Shipping too much would put Chisel and the compilers in a player’s hands — and that is not merely wasted space, because the tools are ordinary GPL binaries and distributing one obliges you to distribute its source too. So ship copies a named list of files, and a test asserts no tool ever appears in the result.

The toolset window

tools/kerosene-tools/src/toolset/ holds Toolset with seven tabs (Home, Assets, Editor, Models, Sound, Build, Archive) switched by the sidebar (sidebar.rs) and ctrl-1..ctrl-7, under a top bar (topbar.rs) with the palette’s search box, Play, and the running jobs. Editor, Models and Sound are the tools they used to be: ChiselApp, loupe, timbre::gui::Timbre. The others are pages in src/pages/: home.rs, assets.rs, build.rs, archive.rs, and start.rs for choosing a project.

Every page returns what was asked for as a toolset::Action, carried out after the frame, so a button, the sidebar and the command palette (kerosene_toolui::palette, fed by toolset/commands.rs) all mean one thing by “build”. Jobs are src/job.rs: the toolset re-run with a subcommand, its output piped back, cancellable as a whole process group. One output panel (kerosene_toolui::output::OutputPanel) receives every job’s log.

pages/assets.rs walks the content tree into an Index once, when the tree may have changed (a job finishing, Home or Assets coming up, Rescan), and Home, Assets, Archive and the palette all read that one index. Recent projects are src/recent.rs, plain lines in the user data directory.

cargo run -p kerosene-tools --example shot -- <tab> out.png draws the window off screen, the way Chisel’s ui_shot draws the editor.

tools/chisel is the editor proper. Its logic — document and undo, grid, viewport projection/picking, tools, compile pipeline — lives in the library and is tested without a window; the egui layer only draws it and turns clicks into calls (tools/chisel/src/lib.rs). That split is deliberate: an editor that can only be tested by clicking is an editor whose bugs are found by its users.

Next: Testing.

Testing

Where a subsystem can be tested without a GPU or a window, it is. This is not an aspiration; it is why several types are shaped the way they are. This page lists the seams that make it possible and the tests that cross them.

What is deliberately not in the testable path

TestableDepends onEnabled by
Whole simulationnothingEngine has no surface
Player movementa CollisionWorldthe trait, and BoxWorld
Entity I/OEntityWorldhandlers take &mut EntityWorld only
Scriptsa WorldView snapshotscripts return ScriptActions
Rendering decisionsBsp + WorldMeshmesh.rs/lightmap.rs are CPU-only
Shadersnagasame compiler wgpu uses
Audio mixingbuffersmixer.rs has no device
Compilersplain dataeach stage is a pure function
The editorthe libraryegui is only the drawing layer

Movement against a hand-made world

crates/kerosene-physics/src/world.rs defines CollisionWorld, implemented by BspWorld for real maps and BoxWorld for tests. BoxWorld is a floor, a step, a wall and a volume; the tests in crates/kerosene-physics/src/movement/tests.rs (855 lines) pin down every rule — friction, the air-speed cap, stair stepping, sliding — against a world whose expected answer is obvious. Testing movement against a compiled map means debugging the map when a test fails.

The test-world feature exposes BoxWorld to other crates without compiling it into a release build.

Entity I/O without an engine

Class handlers are fn(&mut EntityWorld, EntityId, …). That is the whole reason the trait uses function pointers and not closures over an engine: a test can call EntityWorld::spawn, load_from_kv, fire_output, run, and assert on the result with no Engine in sight. crates/kerosene-entity/src/world/tests.rs does exactly that, including the queue ordering and generation-reuse cases.

Scripts as pure functions

crates/kerosene-script/src/lib.rs tests scripts against a hand-built WorldView and asserts on take_actions(). Because a script run is a pure function of the snapshot, the same script run twice on the same world does the same thing — which is a property that can be asserted, not hoped for.

Shaders through naga

crates/kerosene-render/src/gpu.rs has a test module (around line 1513) that parses and validates every WGSL string with naga, the same compiler wgpu uses internally. A typo therefore fails in CI on a headless runner rather than at pipeline creation on a machine with a display — the only machine where the bug would otherwise appear.

The whole pipeline: the engine’s integration tests

crates/kerosene-engine/tests/ holds three suites that go through the entire stack, because each crate can pass its own tests and still not add up to a level you can walk around:

  • playthrough.rs — build a map in memory, compile it through Cleave, load the .kbsp, spawn entities, move the player, fire inputs, walk through a door.
  • streaming.rs — a hall of four rooms; the far room’s crates are in a streamed visgroup and the section comes and goes as the player approaches and leaves.
  • acoustics.rs — the whole acoustic chain: a map compiled through Cleave, Umbra and Resonance, loaded by the engine and listened to, asserting that the room the compiler wrote is the room the engine hears and that a wall the compiler saw is a wall the mixer muffles.

The common fixture tests/common/mod.rs is the stock game as a Game impl, so these suites exercise the same class handlers a real project would.

---
config:
  layout: elk
---
flowchart LR
    lvl(["unit tests"]) --> crate["each crate tests its own logic<br/>against plain data"]
    crate --> integ["engine integration tests:<br/>playthrough, streaming, acoustics"]
    integ --> shader["naga shader validation"]
    shader --> ci["CI: fmt, clippy -D warnings, tests"]
    ci --> build["Windows + macOS: build only"]

    classDef proc fill:#FF6D00,color:#fff
    class lvl,crate,integ,shader,ci,build proc

The compiler tests

Each compiler has its own tests for the stage it owns — tools/cleave, tools/umbra, tools/radiance, tools/resonance — and the engine’s suites are where their outputs meet. Cleave is a library (tools/cleave/src/lib.rs, pipeline::compile), so a test builds a map without shelling out and without a temporary file in between. That is also why Chisel and Kiln can both drive the same compile.

CI

.github/workflows/ci.yml:

  • check (Linux) runs cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo xtask layers, cargo test --workspace, the Steam feature’s clippy and tests, and cargo doc on the published crate with warnings as errors.
  • platforms (Windows and macOS) runs cargo check --workspace --all-targets. The point of those two is to find out the tree still compiles there, which is the question nobody at a Linux desk can answer by hand.
  • bundle folds the workspace into the published crate (cargo xtask bundle), builds and packages it, then makes a game with it and plays it headless.
  • book checks the book’s relative links and builds it; licences runs cargo deny.

Every job must pass; none is allowed to fail. Each Rust job starts with .github/actions/setup, so they all build with the same toolchain and libraries.

Tool crates are compiled at opt-level = 2 in the dev profile (Cargo.toml), because a debug build of Cleave/Umbra/Radiance is brutally slow and an unoptimised Chisel 3D pane is the difference between an editor you can drag around and one you cannot.

Back to Architecture notes.