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-devon Debian and Ubuntu,alsa-lib-develon 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
| Command | Does |
|---|---|
cargo play | Build 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 --full | Build maps with full visibility and lighting first |
cargo play --watch | Keep building while the game runs: save a map in the editor and the game reloads it where you stand |
cargo tools | The toolset window: project, editor, sound editor, build, archive |
cargo tools chisel content/maps/x.kmap | The editor, on a map. F9 in it compiles the map and plays it. |
cargo ship | Build everything properly and assemble dist/, ready to hand out |
cargo run | The game on its own, as a player gets it: the main menu first, with the content as it is |
cargo test | The game’s own tests: game.rs starts with one |
cargo tools clean | Delete 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: the
Gametrait, classes, the schema and your game’s tools. - Saving and level changes.
- Publishing, before you hand anything to anyone.
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"
}
| Key | Means | If absent |
|---|---|---|
name | What the title bar and the shipped README.txt call it | The file’s own name |
content | The content tree, relative to the project file | content/ beside the file, then the file’s directory |
startmap | The map New Game loads from the main menu, and that -nomenu, play and a headless run start on | The base content’s room |
game | The Cargo package whose binary is the game: built and launched by F9, built and copied by kiln --ship | The stock kerosene runtime runs and ships instead |
bin | That package’s binary, when it is not named after the package | The package name |
dir | Repeatable: the directories the content tree is made of, when the standard set is not wanted | The 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_point | Seeing 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(®istry, &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.
Where to read next
- Formats — every file above, byte by byte.
- Scripting — the whole Rhai surface on one page.
- Audio — the
.ksndtable 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 command | Does |
|---|---|
F5, quicksave | Save as quick |
F9, quickload | Load quick |
save <name> | Save under a name: letters, digits, _, - and . |
load <name> | Load one |
saves | List them, newest first |
Saves are files, save/<name>.ksav, in the player’s own directory: the
same place cfg/config.cfg goes.
| Platform | Directory |
|---|---|
| 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:
| Class | Does |
|---|---|
logic_autosave | A checkpoint. Its Save input saves under savename (auto by default). Wire a trigger_once to it. |
trigger_changelevel | Walk 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_landmark | A 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_counterkeeps its count - a wire that fires once, and has, stays spent
- a
logic_autodoes 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();
}
}
}
saveis called for every saved game and every level change.loadis called after the map has loaded and aftermap_loaded, so it overrides whatever a fresh map set up. It is not called whensavereturnednull.
The stock game saves its weapons this way: rounds, the weapon in hand and the dash cooldown.
From code:
| Call | When |
|---|---|
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:
- The licence texts.
LICENSE(the GPL) andLICENSE-EXCEPTION(the Kerosene Exception), in full, beside the game. - A notice that the program is built with Kerosene, what licence the engine is under, and that it comes with no warranty.
- 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. - 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.
- 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. - 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.txtand 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
| What | Why | What it costs you |
|---|---|---|
| Sell the game | The licence does not restrict commercial use | Nothing |
| Keep your game code, levels, assets and scripts closed | The exception’s linking permission: an independent module does not become a covered work by being linked | The permission’s conditions: engine source, the notice, the attribution screen |
| Statically link the engine | Rust does this by default and the exception names static linking explicitly | Nothing more; there is no relinking clause |
| Ship on any storefront | No storefront term conflicts with the licence | The storefront’s own requirements, none of which the engine helps with yet (Platforms) |
Ship the stock kerosene runtime, unmodified, as your game | A content-only project does exactly this; kiln --ship copies the runtime when .kproj names no game package | The notice and licence texts, which kiln --ship writes |
| Fork the engine | The GPL permits it | The 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: Something | The project wants derived engines to be able to say where they came from | The 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 content | There is none. Every format is Kerosene’s own, byte-tagged, and cannot open theirs | Nothing, provided you do not put any of theirs in |
| Let players mod the shipped game | Loose 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
| What | Why | Where it is enforced |
|---|---|---|
| Omit the licence texts or the notice | The exception requires them, and nothing breaks when they are missing, which is why it is the common mistake | kiln --ship writes them unconditionally; deleting them afterwards is on you |
| Remove “built with Kerosene” and the source pointer | It is a condition of the linking permission and a §7(b) notice; without the permission the whole game is GPL | As 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 display | Nothing 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 source | The tools are ordinary GPL binaries. A game that ships no compilers owes nobody anything for them; one that does inherits their obligations | tools/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 terms | The GPL does not permit it; only the copyright holder can | — |
| Present the game as Valve’s, or use “Source”, “Hammer” or Valve marks as branding | Those names appear in these docs nominatively — to say what a thing is analogous to. Branding is a different use and is theirs | NOTICE; the shipped README.txt disclaims affiliation |
Ship a .vault older than the content in it | You would be shipping maps nobody built | check_archive in ship.rs refuses and lists the newer files |
| Ship from a checkout that has not built the archive | There is nothing to copy | ship.rs refuses with “run kiln with no --only first” |
| Expect a warranty | The licence disclaims one; the README.txt says so | — |
| Ship an engine named after Kerosene with its source closed or its origin unstated | The licence grants no right to the name; §2(d) of the exception grants it on conditions | Forks 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.
| What | The drawback | Source |
|---|---|---|
| Ship on Windows or macOS | CI builds both (.github/workflows/ci.yml), so they compile. Nobody has run the result by hand. Budget the time to be the first | README, “Known limits”; missing-features.md §13 |
| Ship a game with save/load | It 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 place | Saving and level changes |
| Ship with a menu, HUD or options screen | Game::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 now | missing-features.md §8 |
| Ship with an options screen for sound | volume is the only sound convar. There is no separate music or effects volume to set | missing-features.md §1 |
| Ship with gamepad support | Keyboard and mouse only; no action-map layer, no rebinding UI | missing-features.md §9 |
| Ship multiplayer | The simulation runs headless, which is the hard part, but there is no wire protocol, prediction or replication | README; missing-features.md §12 |
| Ship with crash reporting | kerosene_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 you | crates/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 be | missing-features.md §2 |
| Ship a modified engine | Legal: 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 first | Forks and derived engines |
| Write gameplay in Rhai instead of Rust | The 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, no | scripting.md |
Let players drop loose files beside the .vault | The 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 archive | tools.md, Vault; crates/kerosene-vfs |
| Install the game somewhere read-only | The 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 run | crates/kerosene-config |
| Rely on the engine being deterministic (replays, ghosts) | Asserted, not audited: nothing replays a session yet to prove it | missing-features.md §4 |
| Ship on Steam | Build 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 yet | Steam |
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:
- 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.
- 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.
- 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
kilnagain first. - The licence texts are compiled into
kiln, so shipping works from an installed toolset that is nowhere near a checkout. --steamadds Valve’s library and nothing else. It builds with thesteamfeature, putslibsteam_apibeside the game with an rpath to find it, and writes SteamPipe scripts next todist/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
| Platform | Status | Notes |
|---|---|---|
| Linux | Developed and tested here | Audio 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 |
| Windows | Compiled in CI; never played by hand | wgpu, 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 |
| macOS | Compiled in CI; never played by hand | Same dependencies, same caveat. wgpu uses Metal |
| Consoles | No | No SDKs, no plans in the tree |
| Mobile | No | Touch input does not exist |
| Web | No | wgpu 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.
| Store | What works | What is missing |
|---|---|---|
| itch.io | Zip the kiln --ship folder and upload it | A butler push target in Kiln |
| Steam | The 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 scripts | Steam Input, lobbies and networking, leaderboard downloads |
| GOG, Epic | The folder, as above | Everything 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 open | Connects as the signed-in user. platform_status in the console says so |
| The same, with Steam closed | Warns once and falls back to the local store. The game runs |
--no-steam | Skips Steam even in a Steam build |
--headless | Never tries Steam: a server has no signed-in player |
| A release build started from its folder rather than from Steam | Relaunches 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
| Class | Keys | Inputs | Outputs |
|---|---|---|---|
logic_achievement | achievement, progressmax | Unlock, Clear, SetProgress <n> [max] | OnUnlocked |
logic_stat | stat, threshold, achievement | Set <n>, Add [n], Increment, Store | OnChanged (the value), OnThreshold |
logic_leaderboard | leaderboard, sort (desc for points, asc for times) | Submit <score> | OnSubmitted, OnRankImproved (the rank), OnFailed |
logic_richpresence | status | SetStatus <text>, SetKey <key> <value>, Clear | |
logic_platform | dlc | Refresh, 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="..."oron_event.
| Event | Data |
|---|---|
achievement_unlocked, achievement_cleared | the id |
achievement_progress | id current max |
stat_changed | name value |
score_submitted | board score rank improved |
score_failed | the board |
dlc_checked | appid owned |
overlay_opened, overlay_closed | |
workshop_mounted | the item id |
UI bindings read the same state from the store:
platform.available,platform.name,platform.userandplatform.overlayplatform.achievements.<id>,platform.stats.<name>andplatform.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_readstore 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:
- Builds with the
steamfeature, intotarget/shipso the everyday build is left alone. A project with no game package gets the stock runtime, built from the engine checkout. - Installs Valve’s redistributable (
libsteam_api.so,steam_api64.dllorlibsteam_api.dylib) beside the game. The copy comes from the build that linked it, so the versions always match. - 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. - Writes the SteamPipe scripts,
steam_build/app_build_<appid>.vdfanddepot_build_<depot>.vdf, besidedist/rather than in it. It then prints thesteamcmdline that uploads them. The depot script excludessteam_appid.txtand*.pdb, so a test file can never reach players. - 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
-
startmapin.kprojnames 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.kuito replace them. -
gamein.kprojnames 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-runlists every stage you expect and no map you have forgotten. -
kerosene-tools kilnruns 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 distsucceeds. If it refuses, it says why: a stale archive means runkilnagain first. (Publishing) -
dist/holds the game binary,<name>.kproj,content/<name>.vault,LICENSE,LICENSE-EXCEPTIONandREADME.txt— and nokerosene-toolsbinary. -
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::uido it. (Publishing) - If you changed any Kerosene file: the whole modified engine is
published where anyone can get it, and the
README.txtand 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_appidin.kprojis your app id, not 480. - Every achievement and stat the game awards is declared in the
.kprojand set up under the same API name in Steamworks.achievement_listin the console shows what the game declares. (Steam) -
kerosene-tools kiln --ship dist --steamsucceeds, anddist/holds Valve’slibsteam_api.so/steam_api64.dll/libsteam_api.dylibbeside the game. - No
steam_appid.txtis in what you upload.--steam-devwrites one for testing, and the depot script excludes it anyway. - Started from Steam,
platform_statussayssteam (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.kcfgcannot 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 & *.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
-
Brushes. Each
.kmapsolid 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. -
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.
-
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.
-
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.
-
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.
-
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:
- Categorise the player’s position: are they standing on something?
- Friction, then acceleration, then the move itself. That order matters: running friction after acceleration makes ground movement mushy and breaks air strafing entirely.
- Update triggers from the player’s box.
- 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:
- Find the viewer’s cluster.
- Take its PVS row — the clusters that can possibly be seen.
- Frustum-cull the leaves in those clusters, then their surfaces.
- 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
.kmapin 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::wav | Source .wav files to samples |
kerosene-audio::compiled | .kaud files to samples — what a shipped game reads |
kerosene-audio::adpcm | Four bits a sample, for the above |
kerosene-audio::mixer | Voices to a stereo buffer — no device, so it is testable |
kerosene-audio::reverb, env, dsp | The room and the air: what the world does to a sound on the way |
kerosene-audio::device | That 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
distanceit 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_reverb | Room reverb; 0 is dry everywhere |
snd_reverb_preset | room, hall, cave or outdoor everywhere, for auditioning; empty uses the map’s |
snd_occlusion | Walls muffle, and no-way-through silences |
snd_air | Distance takes the highs out |
snd_acoustics_debug | 1 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 | |
|---|---|
message | Which sound |
health | Volume, 0 to 1 — named as Source names it |
pitch | Playback rate; 2 is an octave up |
radius | How far it carries; 0 uses the script’s |
looping | Default on |
| Flag 1 | Starts silent, waits for PlaySound |
| Flag 2 | Heard 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.
nameprints a convar and its help;name valuesets 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.txtbesideconfig.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
| Command | Does | Cheat |
|---|---|---|
god | Take no damage | yes |
buddha | Take damage, never below 1 health | yes |
notarget | Go unnoticed; for a game’s AI to read (Engine::notarget) | yes |
noclip | Fly through walls | yes |
kill | Die, god mode or not | |
give <class> [key value]... | Spawn an entity where you stand | yes |
getpos | Print where you are as a setpos; setang line | |
setpos <x> <y> <z> | Move there | yes |
setang <pitch> <yaw> [roll] | Face that way | yes |
ent_fire <target> <input> [param] [delay] | Fire an input at every entity with that name, else that class; !picker is what you are looking at | yes |
ent_create <class> [key value]... | Spawn an entity where you are looking | yes |
ent_remove [name] | Remove what you are looking at, or everything with a name | yes |
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 10 | yes |
phys_spawn [model] | Drop a physics prop in front of you | yes |
restart | Load 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 | |
screenshot | Save 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_writeconfig | Write config.cfg now rather than on exit | |
map_autoreload 1 | Reload 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.
| Key | Default | Meaning |
|---|---|---|
renderer | vulkan | vulkan, metal, dx12, gl, or auto. The backend the window asks for. |
width | 1280 | Window width in pixels. |
height | 720 | Window height in pixels. |
vsync | 1 | 1/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
| Extension | What | Text or binary | Written by | Source analogue |
|---|---|---|---|---|
.kmap | Editable map source | text (KeyValues) | Chisel | .vmf |
.kbsp | Compiled map | binary, lump directory | Cleave / Umbra / Resonance / Radiance | .bsp |
.kprt | Portal graph | text | Cleave | .prt |
.kleak | Leak trace | text | Cleave | .lin |
.kwalk | NPC walkmap | binary | Cleave | (Source has no equivalent) |
.ktex | Texture | binary, resource container | Alchemy | .vtf |
.kmat | Material source | text (KeyValues) | Alchemy, by hand | .vmt |
.kmat_c | Compiled material | binary, resource container | Alchemy | .vmat_c |
texture.kcfg | Texture set definition | text (KeyValues) | Alchemy, by hand | (no equivalent) |
.kmdl | Model | binary, resource container | Forge | .mdl |
.kdef | Entity class definitions | text (KeyValues) | the game, by hand | .fgd |
.kscr | Level script, UI script | text (Rhai) | by hand | .nut (VScript) |
.kui | UI layout | text (XML) | by hand | Panorama .xml |
.kcss | UI stylesheet | text (CSS subset) | by hand | Panorama .css |
.ksnd | Sound script | text (KeyValues) | by hand | game_sounds.txt |
.kaud | Compiled sound | binary | Timbre | .wav (ADPCM) |
.wav .flac .mp3 | Sound sources | binary | any audio tool | .wav |
.vault | Content archive | binary | Vault | .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.
| Lump | Holds |
|---|---|
| entities | KeyValues text, one block per entity |
| planes | Pairs: index n^1 is always the inverse of n |
| vertices, edges, surfedges | Shared vertex positions and the ring indirection |
| faces | Renderable polygons, with lightmap extents |
| nodes, leaves | The BSP tree |
| leaffaces, leafbrushes | What each leaf holds |
| models | Model 0 is the world; 1..n are brush entities |
| brushes, brushsides | Convex collision volumes |
| texinfo, texdata, texdata_strings | Materials and their projections |
| visibility | The run-length-encoded PVS, and the PAS beside it |
| lighting | Baked lightmap samples |
| acoustics | One record per room: how long sound lingers there, per band |
| acoustic_leafs | One u16 per leaf: which room it is in |
| sections | The streamed sections: a name and bounds for each; section 0 is the world |
| face_sections, brush_sections | One 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.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | KRES |
| 4 | 4 | Container version, 1 |
| 8 | 4 | Kind: four bytes naming the payload, KMAT, KTEX, KMDL |
| 12 | 4 | Kind version |
| 16 | 8 | Source hash: 64-bit FNV-1a of the source it was compiled from |
| 24 | 4 | Block count |
| 28 | 4 | Reserved, 0 |
| 32 | 12 × count | Block 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.
| Parameter | What it does |
|---|---|
$basetexture | The colour. Sampled through an sRGB view. |
$bumpmap | Tangent-space normals. The basis comes from the face’s texture projection, so it agrees with the UVs by construction. |
$roughness | Red channel, 0 mirror to 1 matte. Drives the specular term; with no map (and no $roughnessfactor) a dielectric is fully rough and gets none. |
$selfillummask | What the surface emits regardless of light reaching it. Added in linear HDR before tone-mapping, so it rolls off like a lit highlight. |
$ao | Baked occlusion, multiplied into the lighting. Darkens what the surface shadows itself, which the lightmap’s luxels are far too coarse to see. |
$metalnessmap | Red 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.
| Parameter | Default | What it does |
|---|---|---|
$metalness | 0, or 1 with a $metalnessmap | How metallic. "$metalness" "1" is a plain metal with no texture. |
$roughnessfactor | 1 | Scales 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" }
}
| type | draws | parameters |
|---|---|---|
model | the entity’s model, posed by its angles | key (default model) |
lightradius | two 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 | |
lightcone | a cone along the angles, from _cone and _inner_cone (half-angles), honouring the pitch override | |
sphere | a sphere | radius, color |
frustum | what a camera sees | fov (90), length (256) |
direction | an arrow along the angles | length (48) |
line | a line to every entity the key names | key (default target) |
rect | a flat rectangle facing along the angles: a panel, a decal | width, 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 --shipwrites 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
cargodoes 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
| Clause | What it encodes | Why it is allowed |
|---|---|---|
| §1 | You may link Kerosene into a game and ship the game under your own terms | An additional permission (§7, first paragraph) |
| §1(a) | The engine part, with your changes, stays GPL with source | A condition of the permission |
| §1(b) | The game carries “built with Kerosene” and a source pointer | A condition of the permission; also §7(b) |
| §1(c) | A modified engine’s whole source is public, not only offered to recipients | A condition of the permission — the GPL alone could not require publication |
| §1(d) | The game shows the attribution screen | A 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:
| Reaches | Ships in a game |
|---|---|
smartstring (MPL-2.0+), via rhai | the engine |
| Symphonia, six crates (MPL-2.0) | timbre only |
| Licence | Crates |
|---|---|
MIT OR Apache-2.0 (and orderings/spellings of it) | 442 |
MIT | 55 |
Unicode-3.0 | 36 |
Zlib OR Apache-2.0 OR MIT (either order) | 34 |
Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | 12 |
Unlicense OR MIT | 10 |
Apache-2.0 (only) | 10 |
ISC | 5 |
(MIT OR Apache-2.0) AND Unicode-3.0 | 4 |
BSD-2-Clause / BSD-3-Clause / BSD-* OR ... | 9 |
Zlib, CC0-1.0, 0BSD OR MIT OR Apache-2.0, Apache-2.0 AND MIT | 4 |
(MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0 | 1 |
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.
Steamworks, which only a Steam build links
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, which the engine does link
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, which only a build tool links
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, which only a build tool links
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 bycrates/kerosene-map/examples/starter_room.rsfromkerosene_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:
| Module | Follows |
|---|---|
kerosene-math::winding::Winding::split | ClipWindingEpsilon |
cleave::tree::select_split | SelectSplitSide’s scoring |
umbra::flow::clip_to_separators | ClipToSeperators |
kerosene-physics::movement | Source’s gamemovement solver |
kerosene-map::texture base axes | Quake’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 readsEngine::interpolation_alpha()and takes the view angles from the latest input rather than the last tick.No continuous integration.Fixed:.github/workflows/ci.ymlrunsfmt --check,clippy -D warningsand the tests on Linux, and builds on Windows and macOS.No panic hook, no crash log.Fixed:kerosene_console::install_crash_handlerwritescrash.logbeside 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
InputStateper 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 inpositioning.mdis unproven until something replays. No game-state serialization at all.Fixed: saved games are JSON throughserde, level changes carry state across, and saves mirror to the store’s cloud. See Saving and level changes.volumeis the only sound convar an options screen could set. There is no separate music or effects volume.Fixed: the tools’ egui host is nowkerosene-uiis not a UI toolkit.kerosene-toolui, andkerosene-uiis 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, andprop_dynamicplays, 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.
.ktexis uncompressed; no BCn. The README’s reason – a bad encoder is worse than none – no longer holds:intel_tex_2andtexpressoare 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 livemat_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$metalnessand$roughnessfactorscalars, 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, frominfodecal, thedecalcommand, 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_cubemapprobes, 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_staticmodels 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-physicsis the player movement solver plusPhysicsWorld, which wraps Box3D (viabox3d-rust) and is wired into the engine: world brushes become static hulls,detailjoins 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_physicsentities 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 aprop_dynamic_spawnerdrops 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 fixedphys_player_push_forceand the prop’s ownmass, 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 atphys_launch_speedplus 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..kmdlmodels render at their simulated pose, andphys_debugdraws 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_rayandtrace_viewsweep 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.
rayonis on the compile side today, but nothing guards the simulation path against it, or against aHashMapiteration order reaching gameplay. The demo replay in section 1 is the test that would catch it.
5. Animation and characters
Skeletal animation playback.Fixed forprop_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/npcclipexists as a material but nothing is an NPC. - Pathfinding.
NavGraphlinks 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.kwalkwith it, andEngine::find_pathandEngine::navare 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 / reverbFixed: 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. Seeaudio.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.
$surfacepropnow parses into aSurfaceProperty, 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 renderingfor gameplay: a glyph atlas over ab_glyph, with@font-facefor a game’s own fonts.
9. Input
- Gamepad / joystick support. Keyboard and mouse only.
gilrsis the pure-Rust answer; Steam Input replaces it on Steam and adds glyphs. - Input actions.
bindmaps keys to commands, and a game’s own held+actionsare per-tick state (engine.input.action_held("zoom")); the bindings are onEngine, withkeys_forfor 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
OnDamagedoutput on entities. - Inventory / items / pickups. None.
- Objectives / quests / missions. None.
- Dialogue system. None.
Save/load.Fixed:save,load, F5 and F9,logic_autosavecheckpoints. See Saving and level changes.Persistent game flow.Fixed:trigger_changelevelandchangelevelcarry the player’s health and the game’s own state (throughGame::saveandGame::load) into the next map, lined up by aninfo_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,tracktrainandpath_track,logic_case,logic_compare,math_remap,filter_activator_nameand_class,env_fade,env_shake,trigger_gravity,point_viewcontrol,env_sprite,game_ui. None of these are hard; each is a class inkerosene-gameand a row in the schema – or in a game’s own crate, since a game registers classes through theGametrait without touching the engine. A game’s class with amodelis drawn and collided with when it says how (ClassDef::model(ModelRole::Physics)); until this audit only the three stockprop_*names were.
11. Content and asset pipeline
- Prefabs / instances. Brushes are authored per map; there is no way to
include one
.kmapin 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.
.kmatis 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 (seearchitecture.md). Still one.kbspper map, compiled and lit as one; not an open world.- Visibility control for the mapper.
tools/hintandtools/skipsteer Cleave’s splits, but there is noareaportaland 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 newmakes a game crate (aGamewith a class of its own, its toolset binary,cargo play,cargo toolsandcargo ship, a project file and a starter map), or with--content-onlya mod. The engine’s base content means either runs before it has any art of its own.- Per-target build settings.
.kprojnames a content tree and a start map, and the window title comes from the game’sLaunchOptions. 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.
--headlessexists, 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.sentrycovers both; Breakpad minidumps beside the log are the no-service option. - Installer / auto-updater. None.
For a game,build-content.shis a shell script.cargo playandcargo shipare the build, and they run anywhere Cargo does. The script only builds this repository’s base content and the base vault.
14. Integrations
-
SteamworksIn, as the opt-insteamfeature (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 uploadfor putting a.vaultup
All of it is reachable from entity I/O (
logic_achievement,logic_stat,logic_leaderboard,logic_richpresence,logic_platform), from Rhai (theplatformobject, aliassteam) and from game code.kiln --ship --steaminstalls 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 traitkerosene-platform’sBackend: 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
--watchmode 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.ksnddoes 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
.kmapdiff – 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_speedsreports counts; a frame timeline is what finds the hitch.puffindraws in egui and drops straight into the console overlay;tracy-clientis the other answer. stat-style overlays.r_speedsandphys_statsexist; 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 apausecommand does it whatever is open.No focus handling.Out of focus, the game pauses andsnd_mute_losefocussilences it; minimised, it stops drawing.The window was always called “Kerosene”.The title and desktop app id are the game’s own, fromLaunchOptions.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 aremaps,revertandhost_writeconfig.config.cfgis 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,restartandhost_timescaleare in; see the console. Still nor_wireframe,host_framerate, orent_textdrawn in the world (ent_infoprints to the console).- Video.
Fullscreen,are in. Still no render scale, texture-quality or anisotropy settings, and no UI text scale.-w/-h/-fullscreen, a clampedcl_fov,screenshot - 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::Eyesis 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 buttonsdo now:noise_move,noise_stopandnoise_locked,door/moveby 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; noparentname; nodoor_rotating,point_template/env_entity_maker,point_teleport,trigger_look/trigger_proximity,game_textorsky_camera. - Scripting. No traces, spawning, or entity angles and velocity in the
Rhai bindings.
Random numbersare in:rand,rand_range,rand_intandpick, 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_lockedexists; 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 copiedcan 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: there is one now.kerosene-tools doctor - The texture stage writes the developer textures into every project’s
art/andmaterials/, 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.gitignoreleaves 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_rayandtrace_viewreturn aTraceHitwith 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 toprop_static,prop_physicsandprop_dynamic. A class now says so itself:ClassDef::model(ModelRole::Physics).No camera control.view_punch,screen_shake,set_fov_override,set_view_angles, andset_camerawith aCameraOverridefor a cutscene or a death camera. The stock weapons kick.A game could not read its own held actions.+zoomran as a command once a frame;engine.input.action_held("zoom")is per-tick state now.The bindings belonged to the window.They areEngine::input, so a rebinding screen can read and change them, andbindworks headless.Death was always an instant respawn.Game::player_damageddecides how much a hit takes andGame::player_diedcan take the death over, leaving the player dead untilEngine::respawn_player. Maximum health is the game’s to set.Missing lifecycle hooks.frame(every frame, paused or not),player_spawned,can_save,map_unloadingandshutdown.No debug drawing for a game.debug_line,debug_boxanddebug_point, for a number of seconds, underr_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_intandpickfor 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
fnpointers. They cannot capture settings or reach the game’s state; everything goes through a string-keyedHostRequest. No touch or damage callbacks on a class.ClassDef::on_touchandon_damage, withEngine::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.
platformanduireturnResult<_, String>, the engine mixes that withanyhow, the rest use typed errors. Unifying them is a breaking change, so it wants doing before1.0.0.
Rendering:
On the world, an alpha-tested surface is cut out, a translucent one is blended back to front after everything solid, and$alphatest,$translucentand$nocullwere parsed and ignored.$noculldraws 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 isdetail.- No sprites, billboards or beams. Particles need them first.
- No render-to-texture or second camera. Security monitors, mirrors,
scopes and
sky_cameraall wait on it. - No model attachments or hitboxes. No muzzle point, no held item, no headshot.
No brightness or vsync at runtime.mat_gammaandr_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.--portablekeeps them in the content tree.crash.logis still written beside the executable.- No local mods folder or
-gameflag. 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::savedata. No save thumbnails. No save or load menu.The pause menu saves and loads, and the main menu loads.
Entities:
- Missing classes.
are in, withbreakable,wall_toggle,point_hurt,item_*pickups,game_end,player_speedmodpoint_teleport. Still missing:physbox,env_spark,env_explosion, and fog and water controllers. - Thin common inputs.
Kill,AddOutputandFireUser1to4work on everything;SetParentdoes not, andEnable/Disableare each class’s own rather than universal.
The tools:
It clears only a directory it made.cargo xtask bundle --outemptied whatever it was given.It exits 0.cargo xtask helpfailed.The published README’s links were broken.The bundle points them at GitHub.Both fixed; the bump also moves the changelog’s comparison links.scripts/install-desktop.shstopped on a missing icon, andbump-version.sh’s changelog step needed GNU sed.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.A game called Kerosene isnewcould make a game that depends on itself, and made no repository, CI or test.kerosene-game;newrunsgit initand writes a CI workflow,.gitattributes,rust-versionand 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
newcould ask. No--watch.kiln --watchandplay --watch; the game reloads a rebuilt map undermap_autoreload.
Code health:
The stable crates’ public items were half undocumented.math,physics,script,vfs,game,consoleandplatformare documented and warn on anything new that is not.entity(about 160 items),ui(about 280) andengine(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.rsis 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_dynamicshows its rest pose, and a model’s skin or body group is not previewed. infodecalandpoint_worldpaneldraw 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 templatesand anewcommand: 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
statcommands, 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
- The walkmap has a consumer now. The engine loads each map’s
.kwalk,NavGraphlinks its faces (across T-junctions, which it used to miss, leaving a real floor with no links at all) andEngine::find_pathanswers with waypoints;Engine::debug_linecan draw them. What is missing is the NPC that walks them. - The solver already does convex hulls; props do not use them.
PhysicsWorldexposesadd_dynamic_hull, and world brushes anddetailgo in as static hulls, so the hull path is real and running. Butprop_physicsbodies are built from the model’s bounding box (add_dynamic_box_material), so a barrel collides as a crate. Nothing callsadd_dynamic_hull. Closing this is wiring a hull out of.kmdlgeometry, not new physics. $surfacepropis 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.
Interpolation, MSAA, runtime mipmaps, texdata dimensions.Done.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.- Demo record and playback. Proves determinism, becomes the movement solver’s regression fixture, and is the ghost system for genre 3.
A game UI layer and save/load.Done, with a main menu, a loading screen and save and load pages.- Chisel: instances. VisGroups and autosave are in; this is what remains of making the second real map editable.
- Weapons and damage. Hitscan, ammo,
OnDamaged, decals for the holes. Steam, Workshop first.Done, as thesteamfeature.- 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 updatethat 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 onecargo docaway.
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”:
- Skeletal animation.
.kmdlstores 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. - 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.
- 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.
| Function | When |
|---|---|
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.origin | Read-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 .overlay | Read |
.is_unlocked(id) .stat(name) .owns_dlc(appid) .achievements .stats | Read |
.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
importfails. 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_fireis 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
| Extension | What |
|---|---|
.kui | Layout: XML |
.kcss | Stylesheet: a subset of CSS |
.kscr | Behaviour: Rhai, the same language as level scripts |
.ttf .otf | Fonts, named by @font-face |
| images | Ordinary textures: art/ui/logo.png is compiled by Alchemy and named ui/logo |
The stock game ships these under content/ui/:
| File | Shown |
|---|---|
ui/hud.kui | While a map is running (the ui_hud convar) |
ui/overlays/damage.kui | By the HUD’s script, on the overlay layer |
ui/menus/pause.kui | By Escape (the ui_pausemenu convar) |
ui/panels/status.kui | On 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 |
z | Draw order among layers. Higher is on top |
reference-height | How 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 | |
|---|---|
Panel | A box. Everything is built from these |
Label | Text: text="...", or the element’s own text |
Image | src="ui/logo", a compiled texture. Fits contain unless styled otherwise |
Button | Clickable and focusable. text= gives it a label |
TextEntry | A text field. value, placeholder, maxlength, password |
Slider | min, max, step, value |
Toggle | A checkbox. checked, and text= for its label |
ProgressBar | value from 0 to 1 |
Repeat | Its children, count times. Each copy sees index (rename with as=) |
Include | src= 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, style | As 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 onblur | Code 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
visibleandclass: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.NAME | Any convar a layout reads |
platform.available platform.name platform.user platform.overlay | The store: Steam, or none |
platform.achievements.ID platform.stats.NAME platform.dlc.APPID platform.names.ID | Every declared one; names are the display names. See Steam |
The stock game (kerosene::game::Stock) adds its weapons and ability:
| Key | |
|---|---|
weapon.active weapon.slot | pistol, shotgun, rifle |
weapon.ammo weapon.clip weapon.reserve | |
weapon.reloading weapon.reload_progress weapon.firing | firing is true for a moment after each shot |
weapon.count, weapons.N.name weapons.N.slot weapons.N.active | For a Repeat over the slots |
ability.dash.ready ability.dash.charge ability.dash.remaining | charge 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).
| Event | Data | From |
|---|---|---|
player_damaged | amount | engine |
player_died | cause | engine |
map_loaded | map name | engine |
weapon_changed | from to | stock game |
weapon_fired | weapon | stock game |
weapon_empty weapon_reload weapon_reloaded | stock game | |
ability_used ability_ready | ability | stock game |
achievement_unlocked stat_changed score_submitted overlay_opened … | see Steam | the 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 | |
|---|---|
| Box | width height min-* max-* margin padding (and -left etc.) border border-width border-color border-radius. Sizes include padding and border (border-box) |
| Flex | display (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 |
| Position | position (relative, absolute) left top right bottom inset z-index overflow: hidden |
| Paint | background-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) |
| Text | color font-family font-size font-weight font text-align vertical-align text-shadow letter-spacing line-height text-transform: uppercase white-space: nowrap |
| Motion | transition animation with @keyframes |
| Input | pointer-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: additive | Add the panel onto what is behind it: glows, flashes, hit markers |
-kero-tint: red | Multiply 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.
hudis managed by the engine. It showsui_hudwhenever a map is running.menuis Escape’s while a map runs: it togglesui_pausemenu. With no map running it holds the main menu,ui_mainmenu. While a layer markedinteractiveis 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.loadingholdsui_loadingfor the frame before a map or a save loads.splashholdsui_splashfor 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.
| File | What it is |
|---|---|
ui/menus/main.kui | New Game, Load, Options, Quit. New Game runs newgame, which loads the project’s start map |
ui/menus/pause.kui | Resume, Save, Load, Options, Restart, Quit to menu (disconnect), Quit |
ui/menus/options.kui | The settings both menus include |
ui/menus/saves.kui | The list of saves both menus include; pressing one calls the including layout’s pick_save(name) |
ui/menus/loading.kui | The 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 | |
|---|---|
layout | The .kui to show |
width height | Size in world units |
resolution | Texture height in pixels. The width follows the shape |
brightness | How brightly it glows; 1 is a surface lit to full |
interactive | Whether the player can use it |
angles | It 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.
infodecalentity:texture(a material) andsize. 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, orengine.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_reload | Load 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_dump | Every published value, every layer, and what is under the pointer |
ui_debug 1 | Outline every panel |
ui_hotreload 0 | Stop watching the files |
ui_hud ui_pausemenu | The 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:
- Engine: the engine’s own parts (today, the level-streaming workers).
- Modules: the ones a game hands over in
Game::modules. - Game: the game itself, through
Game::setupandGame::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:
| Tab | Keys | What it is |
|---|---|---|
| Home | ctrl-1 | the project, its content, what needs building, recent activity |
| Assets | ctrl-2 | every file in the content tree, searchable, with its build status |
| Editor | ctrl-3 | Chisel, the world editor |
| Models | ctrl-4 | Loupe, the model viewer |
| Sound | ctrl-5 | Timbre, the sound editor |
| Build | ctrl-6 | Kiln: stages, options, one button, and Cancel |
| Archive | ctrl-7 | Vault: 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 6 | select, block, entity, texture, shape, clip tool |
Shift+1 .. Shift+4 | select tool, picking objects, vertices, edges or faces |
M | the asset browser, docked along the bottom |
F | frame the selection (everything, with nothing selected) |
F1 | every shortcut, on one sheet |
| Double-click | object properties of what was clicked |
Alt+Enter | object properties, as a window |
Ctrl+Shift+E | the entity report |
Ctrl+X / Ctrl+C / Ctrl+V | cut / copy / paste (a paste lands a grid step over; a cut pastes back in place) |
Ctrl+J | go to an object by id (the brush 12 in Cleave’s warnings) or by part of a name or class |
Ctrl+1 .. 9 | fly a pane to a camera bookmark; Ctrl+Alt+1 .. 9 sets one (kept for the session) |
Ctrl+G / Ctrl+U | group / ungroup the selection |
H / Ctrl+H / U | hide the selection / hide everything else / unhide all |
Ctrl+Shift+G | a new visgroup of the selection |
Enter (clip tool) | cut along the laid line; 6 again cycles what is kept |
Ctrl+Shift+C / Ctrl+Shift+H | carve / hollow |
Ctrl+M | transform: rotate, scale or move by numbers |
R | rotate 90 degrees about the axis the pane looks along |
Ctrl+L / Ctrl+I | flip horizontally / vertically |
Ctrl+B | align the selection to the grid |
Shift+Space | maximise the pane under the pointer, or put the layout back |
[ ] | finer / coarser grid |
Ctrl+Z / Ctrl+Shift+Z | undo / redo |
Ctrl+S | save (asks for a name the first time) |
Ctrl+Shift+S | save as |
Delete | delete selection |
Escape | clear selection, cancel a drag |
F9 | compile (fast) and run |
| In a 3D pane | |
W A S D | fly forward, left, back, right |
Q E | fly straight down and up, whichever way you are looking |
Shift / Alt | fly 2.5x faster / 4x slower |
Ctrl+scroll | set the fly speed |
| Right-drag | look around |
| Middle-drag | slide the camera sideways and up |
| In a 2D pane | |
| Middle-drag | pan |
| Scroll | zoom about the pointer |
| Shift-click | add to or remove from the selection |
| Drag a grip | resize 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:
| green | the target exists and takes that input |
| yellow | the target is !activator, !caller or !player: who that is is decided while the game runs, so only the input is checked |
| red | nothing 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 | |
|---|---|
| wedge | A ramp: a box with one top edge pulled down to the floor. One brush. |
| cylinder | A pillar or a pipe. One brush however many sides — a convex polygon swept along a line is still convex. |
| cone | A spike or a pyramid. One brush. |
| arch | A doorway, a tunnel mouth, a round window: a fan of brushes, one per segment. |
| stairs | Solid 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/clipface on an otherwise ordinary box stops the box being a wall. - A misspelt tool material is not an error.
tools/clippcompiles 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.
| Material | Effect |
|---|---|
tools/nodraw | Solid, never drawn. The workhorse. |
tools/clip | Blocks players; invisible; bullets and sight pass through |
tools/npcclip | Blocks AI only |
tools/trigger | Not solid, but traces find it |
tools/skybox | Draws as sky; where sunlight enters the world |
tools/hint | Forces a BSP split along its plane, then vanishes |
tools/skip | Does nothing — what a hint brush’s other faces wear |
tools/blocklight | Casts a shadow without being solid |
tools/grate | Blocks movement and bullets; you can see through it |
tools/water | Water |
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 | |
|---|---|
rt60 | Seconds for each of 125, 500, 2000 and 8000 Hz to fall 60 dB |
predelay | Time before the first reflection: the round trip to the nearest wall |
openness | The share of rays that escaped to the sky; over 0.35 is outdoors |
wet | How loud the room is next to the sound itself |
diffusion | How 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_override | The 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 | |
|---|---|
light | A point light. _light is "r g b brightness". |
light_spot | A cone. _cone, _inner_cone, _exponent, pitch. |
light_environment | The sun and the sky. _light is the sun, _ambient the fill. |
env_cubemap | A 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_accelerate | movement tuning |
sv_airaccelerate sv_air_max_wishspeed | air control — see below |
sv_jump_height | jump height in units; the impulse is derived from it |
sv_stepsize | tallest step walked up without jumping (18) |
cl_fov sensitivity m_yaw m_pitch | view and mouse |
r_drawworld r_fullbright r_lightmap r_novis | rendering toggles (cheat) |
r_speeds | per-frame culling and draw statistics |
sv_stream sv_stream_linger r_stream_debug | streamed sections: on/off, seconds a section lingers, draw their bounds |
mat_exposure | overall brightness, applied by the tone-map pass |
mat_tonemap | tone curve: 0 none (clip), 1 Reinhard, 2 ACES filmic (default) |
r_msaa | multisample anti-aliasing: 0/1 off, anything higher 4x (default) |
r_bumpmap r_specular | material debug scales: 0 removes normal maps / reflections, 2 exaggerates (cheat) |
r_dynamic r_shadows | dynamic lights (light_dynamic, the flashlight) and their real-time shadows |
flashlight / cl_flashlight | toggle the flashlight (bound to F) / whether it is on |
volume snd_reverb snd_reverb_preset | sound; see audio.md |
sv_pause_on_menu / pause | stop 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_losefocus | silence the game while its window is in the background (default on) |
developer | verbosity; 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:
| Bump | Means for a game |
|---|---|
Major, 2.0.0 | Something a game could have used changed or went away. The changelog says what to change. |
Minor, 1.3.0 | Something was added. A game that built before still builds, and plays the same. |
Patch, 1.2.1 | Something 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, andkerosene::prelude. - The modules
kerosene::engine,entity,math,console,physics,script,ui,platform,vfs,gameand, with thetoolsfeature,tools. - The third-party crates
kerosenere-exports for a game’s own use:egui,glam,rhai,winit,serde_json,anyhowandlog. A breaking release of one of them reaches Kerosene only in a major release. - The Cargo features
audio,steamandtools. - What a game’s content relies on: the entity classes and their keys, inputs
and outputs; the
.kprojkeys; 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 in1.2loads and plays in1.3. - The file formats, as the next section says.
- The minimum Rust version,
rust-versioninCargo.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,renderandwalk, andphysics::rigid. They are public because tools and ambitious games need them, and they change as the engine does.- Anything
#[doc(hidden)], such asEngine::levelandEngine::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,EngineConfigandkerosene_tools::Optionsare#[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,PlatformActionandStatKind. - Enums the engine dispatches on are not
#[non_exhaustive].ScriptAction,UiAction,PlatformEvent,WeaponEventand the twoValueenums 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-checkscompareskerosene’s API with the last release, as a step of releasing. During the1.0.0pre-releases it reports; from1.0.0it blocks the release.
Formats
Compiled files and saved games have their own version numbers, apart from the crate’s:
| Format | Magic | Version |
|---|---|---|
| Compiled map | KROS | 2, and each lump its own |
| Model | KRMD | 2 |
| Texture | KRTX | 1 |
| Sound | KRAU | 1 |
| Archive | KVLT | 1 |
| Acoustics lump | ACST | 1 |
| Cubemaps lump | KCUB | 1 |
| Walk data | KRWL | 1 |
| Map source | .kmap | 1 |
| Saved game | .ksav | 1 |
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
- 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 before1.0.0and a gate after. - Base content. If anything under
content/that the engine’s base content holds has changed, runscripts/build-content.sh, which repackscrates/kerosene-engine/base/base.vault.cargo test -p kerosene-engine basefails when it is stale. - Green.
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warningsandcargo test --workspace, and CI green on every job.cargo +<rust-version> check --workspacewith therust-versionfromCargo.tomlkeeps 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:
| Finding | What happens |
|---|---|
| This version is already on crates.io, or older than one that is | Stops. Bump it with scripts/bump-version.sh. |
| Uncommitted changes | Warns, lists them, and asks |
| Commits not pushed, or no upstream branch | Warns and asks |
| This version is already tagged, at an older commit | Warns — the bump was probably forgotten — and asks |
CHANGELOG.md has no section for this version | Warns 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 newthencargo playagainst 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 engine | Making games |
| a reference for one feature | Documentation |
| the internals of one subsystem | Devnotes |
| Status | 1.0.0-a4 (alpha) |
| Last reviewed | 2026-09-30 |
| Owner | T342 |
| Scope | The workspace in this repository. The demo game lives in a separate repository (kerosene-demo). |
Chapters
- Goals and scope
- Design principles
- Architecture
- Runtime
- Data and the build pipeline
- World and entities
- Subsystems
- Tools
- Public API
- Quality
- Decision log
- Status and roadmap
- Open issues
Editing this document
- One chapter is one file in
src/sdd/. Edit it, and keep the chapter list above and insrc/SUMMARY.mdin 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.pyover 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
- A game is a crate. A game implements the
Gametrait, registers its own entity classes and callskerosene::launch. The engine owns no game. - Fast runtime, slow build. Everything expensive (visibility, lighting, acoustics, texture and model compilation) happens in tools. The runtime loads compiled files only.
- 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.
- Headless by construction. The simulation runs with no window, GPU or input device, so it can be tested and later hosted as a server.
- Stable where it matters. One lockstep version, SemVer over the facade’s stable modules.
- 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.
.kmapsource is linked only by tools. - No networking yet. There is no client/server protocol, replication or
prediction. The headless
Engineis 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
| User | Uses | Cares about |
|---|---|---|
| Game author | the kerosene crate, the tools | a stable API, content that builds and ships |
| Level designer | Chisel, the unified tools app | editor ergonomics, fast iteration |
| Engine contributor | the workspace | layering, 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
| Convention | Rule | Reason |
|---|---|---|
| Fixed tick | Simulation runs at sv_tickrate (default 64 Hz); rendering interpolates | Deterministic gameplay, smooth display |
| Request-shaped I/O | Handlers leave a HostRequest/console request instead of reaching into the engine | Keeps entities and console plain data, testable without an Engine |
| Declare once | Fields carry reflection attributes (Key, Help, Label, …) | One source for loader, saves, editor, scripts |
| Facade over dependencies | bevy_ecs, bevy_reflect, wgpu reached through our own crates | The pinned third-party version can change without a public API change |
| One version | Every crate shares a version, pinned with = | No skew between crates |
| Units | 1 ku = 1 inch, Z up | One convention for tools and runtime |
See also
- Crate map
- Architecture
- Refactor design document (the audit that set these rules)
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
| Layer | Holds | Notes |
|---|---|---|
| 0 core | units and geometry, KeyValues text, console and logging | No engine concepts |
| 1 core services | engine.kcfg, reflection and ECS facades, the file system, the store seam | kerosene-vfs finds the content root; kerosene-platform hides Steam |
| 2 data and hardware | compiled formats, the scene contract, the GPU interface | kerosene-rhi is the only crate that opens a GPU; kerosene-bsp has no GPU dependency |
| 3 subsystems | one capability each | Independent of one another |
| 4 host and game | Engine, Game, host, stock classes | The only place subsystems meet |
| 5 tools | editor and compilers | Never linked by the runtime |
| 6 facade | kerosene | The only published crate |
| 7 runtime | kerosene::launch(Stock, …) | Nothing else |
The four seams
Layering says what may not connect. Four seams say where connection happens.
| Seam | Where | Purpose |
|---|---|---|
| Game | Game trait, crates/kerosene-engine/src/game.rs | A game plugs in rules without the engine knowing it |
| Platform | Engine (no surface) vs host | Simulation without a window or GPU |
| Content | kerosene_vfs::root and toolchain | One answer to “where is the content, and which binary runs the next stage” |
| Format | kerosene-asset, kerosene-map, kerosene-bsp | Writer 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
| Concern | Choice | Reached through |
|---|---|---|
| ECS | bevy_ecs (exact pin) | kerosene-ecs |
| Reflection | bevy_reflect (exact pin) | kerosene-reflect |
| GPU | wgpu 25 | kerosene-rhi |
| Windowing | winit 0.30 | kerosene-engine::host |
| Tool and in-game debug UI | egui 0.32 | kerosene-toolui, host |
| Physics (rigid) | box3d-rust | kerosene-physics::rigid |
| Scripting | rhai | kerosene-script |
| Game UI layout | taffy | kerosene-ui |
| Audio output | cpal | kerosene-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 callsEngine::frameonce 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):
- installs the logger and crash handler;
- parses arguments (
--content,--vault,--headless,+map,+cvar); - finds the content root, project (
.kproj) andengine.kcfg; - 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:
game.pre_tick: change movement parameters before they are read- player movement (
kerosene_physics::player_move), footsteps, listener - triggers, then
entities.run(events, thinks, removals) - engine and game answer entity requests
game.tick: game rules, after I/O and before props step- 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.
| Group | Hooks |
|---|---|
| Registration | classes, schema, setup |
| Map lifecycle | map_loaded, map_unloading |
| Per tick | pre_tick, tick |
| Host requests | entity_request, console_request |
| UI | wants_ui, ui, ui_event |
| Saves | save, load, can_save |
| Events | platform_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.
| Kind | Source | Compiled | Compiled by |
|---|---|---|---|
| Map geometry | .kmap | .kbsp | cleave |
| Visibility | (from .kbsp) | .kprt / PVS in .kbsp | umbra |
| Lighting | (from .kbsp) | lightmaps in .kbsp | radiance |
| Acoustics | (from .kbsp) | acoustics in .kbsp | resonance |
| Navigation | (from map) | .kwalk | map build |
| Textures and materials | images, .kmat, texture.kcfg | .ktex, compiled .kmat | alchemy |
| Models | OBJ, glTF | .kmdl | forge |
| Sound | audio files, .ksnd | .kaud | timbre |
| Packaging | a content tree | .vault archive | vault |
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
- Put the reader and writer in a crate at layer 2 so both sides compile from the same structs.
- Give it a short
k…extension and add it to Formats. - Compile at build time; the runtime must not need the source format.
- 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 world | Entities | |
|---|---|---|
| Held by | kerosene-bsp (.kbsp) | kerosene-entity (EntityWorld) |
| Changes at run time | no (sections stream in and out) | yes |
| Used for | traces, visibility, rendering, acoustics, navigation | everything 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.
rhiopens the GPU and surface (the only crate that does);sceneis the CPU-side description of what to draw (camera, world mesh, lightmaps, lights, probes, decals, UI display list);materialholds materials on the GPU;renderis 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 theCollisionWorldtrait. - Props are rigid bodies in a
PhysicsWorldbacked bybox3d-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;
cpaloutput is behind theaudiofeature. - Acoustics are baked by
resonanceinto 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
| Tool | Does | Source-engine analogue |
|---|---|---|
chisel | Level editor: brushes, entities, 3D view, build | Hammer |
cleave | CSG, BSP, portals, leak detection | vbsp |
umbra | Visibility (PVS) | vvis |
radiance | Lighting bake | vrad |
resonance | Acoustics bake | none |
alchemy | Textures and materials | none |
forge | Models from OBJ/glTF | none |
timbre | Sound compile and edit | none |
vault | Pack and inspect .vault archives | none |
loupe | Inspect compiled files | none |
kiln | Build and ship a whole project | none |
kerosene-tools | One egui app hosting all of the above, plus headless subcommands | none |
kerosene-toolui provides the shared egui theme and widgets.
Design decisions
- One window, many tools.
kerosene-toolshosts 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
toolsfeature of thekerosenecrate 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 onPATH.
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.
| Part | Contents | SemVer |
|---|---|---|
| Root | launch, LaunchOptions, Game, Engine, EngineConfig, VERSION, prelude | covered |
| Stable modules | engine, entity, math, console, physics, script, ui, platform, vfs, game | covered |
tools (feature) | the tool crates, for a game that ships an editor | covered |
internals | asset, audio, bsp, config, kv, map, render, walk, … | not covered |
| Re-exported third parties | egui, glam, rhai, winit, serde_json, anyhow, log | follows 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 byscripts/bump-version.sh. - Bare-version git tags.
- Pre-release (
1.0.0-aN) versions carry no compatibility promise. Breaking changes are recorded inCHANGELOG.mdunder 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 rules | implement Game |
| Add entity classes | register in Game::classes; describe in Game::schema |
| Add console commands and convars | register in Game::setup |
| Handle custom entity or console verbs | Game::entity_request / console_request |
| Draw HUD or menus | Game::wants_ui and Game::ui, or the .kui UI |
| Persist game state | Game::save / Game::load |
| React to the store | Game::platform_event |
| Reuse the stock game | kerosene::game::Stock |
See also
Quality
Strategy
The architecture is arranged so most behaviour is testable without a window, GPU or audio device:
Engineis headless. Integration tests incrates/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-kvandkerosene-bsphave unit tests with no engine. - GPU-free draw tests.
sceneand the UI produce draw data that tests inspect directly; a software rasteriser covers editor views. - Offline platform.
kerosene-platformhas a stand-in backend, so store code is tested without Steam. The real Steam backend is tested separately. - Layering as a test.
cargo xtask layersfails 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
| Check | Command |
|---|---|
| Build | cargo build --workspace |
| Test | cargo test --workspace --locked |
| Format | cargo fmt --all --check |
| Lint | cargo clippy --workspace --all-targets --locked -- -D warnings |
| Layering | cargo xtask layers |
| Public docs | RUSTDOCFLAGS=-D warnings cargo doc -p kerosene --no-deps --features tools --locked |
| Steam backend | cargo test -p kerosene-platform --features steam |
| Publishable crate | cargo xtask bundle |
| Book links | python3 scripts/check-links.py |
| Book build | mdbook build |
Continuous integration
.github/workflows/ci.yml runs on pushes to MASTER and on pull requests:
| Job | Checks |
|---|---|
check (Linux) | fmt, clippy, layers, tests, Steam tests, docs |
platforms | builds on Windows and macOS |
bundle | builds, packages and plays the published crate |
book | link check and mdBook build |
| deny | cargo-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
launchboot 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.
| Phase | Work | State |
|---|---|---|
| 0 | Audit against the checklist | Done |
| 1 | Layer the dependency graph; add cargo xtask layers; remove unused edges | Done |
| 2 | Compiled resource container and Resource<T> handles | Done |
| 3 | Reflection (kerosene-reflect) | Done |
| 4 | ECS (bevy_ecs behind kerosene-ecs); stock classes ported | Done |
| 5 | Split render and physics (rhi, material, scene, PhysicsWorld; engine/host split) | Done, with leftovers |
Leftovers
PhysicsPropsstill lives in the engine and owns the entity-to-body mapping.- Render texture and model loading do not yet go through
Resources. .kmatruntime fallback is incomplete.kiln --forcedoes not reach the texture pass.
Feature status
| Area | State |
|---|---|
| Fixed tick, interpolation | Done |
| BSP, PVS, baked lighting and acoustics, streaming | Done |
| Entity I/O, Rhai scripting | Done |
Game UI (.kui/.kcss/.kscr) | Done |
| Saves and level changes | Done |
| Steam (achievements, stats, cloud, Workshop) | Done, optional feature |
| Unified tools app, Chisel on GPU | Done |
| MSAA, CI | Done |
| Demo record and playback | Not started |
| Chisel instances | Not started |
| Weapons and damage (hitscan, ammo, decals) | Not started |
| Impact sounds | Not started |
| Animation-driven NPCs | Not 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
internalsboundary) 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
- Missing features
- Phase 0 audit
CHANGELOG.mdat the repository root
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
| # | Issue | Where |
|---|---|---|
| O1 | Crate 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 |
| O2 | The 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 |
| O3 | phase-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 |
| O4 | src/refactor/ and src/docs/Devnotes/reworkConfig.md are not linked from SUMMARY.md. | src/SUMMARY.md |
Code and content
| # | Issue | Where |
|---|---|---|
| O5 | Stale 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 |
| O6 | The 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 |
| O7 | PhysicsProps and texture/model loading still bypass the Phase 5 design. | see Status and roadmap |
| O8 | ui → script is a recorded layering exception waiting for a better home for the shared Rhai code. | xtask/src/layers.rs |
Demo repository
| # | Issue |
|---|---|
| O9 | kerosene-demo depends on kerosene = "1.0.0-a2" and uses old extensions (.keromap, .keroproj, .keroscript, .kerosnd); a4 uses .kmap, .kproj, .kscr, .ksnd. |
Testing
| # | Issue |
|---|---|
| O10 | No 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.
| Note | What it covers | Primary sources |
|---|---|---|
| Crate map | Who depends on whom, and the four seams | Cargo.toml, crates/kerosene/src/lib.rs |
| Runtime and the tick | Engine, Game, host, the 64 Hz loop | crates/kerosene-engine/src/{engine,game,host}.rs |
| The map pipeline | Cleave, Umbra, Resonance, Radiance | tools/{cleave,umbra,resonance,radiance}/src/ |
| BSP internals and traces | The lump format, tree queries, brush traces | crates/kerosene-bsp/src/ |
| Entities and scripting | EntityWorld, the event queue, Rhai | crates/kerosene-entity/src/, crates/kerosene-script/src/ |
| Rendering and streaming | Mesh build, lightmap atlas, wgpu, PVS | crates/kerosene-render/src/ |
| Physics | Source movement and rigid props | crates/kerosene-physics/src/ |
| Audio and acoustics | Mixer, reverb, Resonance data path | crates/kerosene-audio/src/ |
| Tools and the build | The unified toolset, Kiln, Vault | tools/kerosene-tools/src/, tools/kiln/src/ |
| Testing | What is tested without a GPU, and why | every crate’s tests modules |
The three rules the whole tree keeps
-
Nothing points upward.
kerosene-mathknows 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. -
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.
-
The engine owns no game.
kerosene-enginedefines aGametrait and runs whatever implements it. The stock game lives inkerosene-gameand 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::methodreference means that method exists; the surrounding prose says what invariant it maintains. - Diagrams use Mermaid with
layout: elk(seebook.toml). Rectangles are data or code, rounded shapes are processes, and colours group a subsystem. `sv_*`and`r_*`names are console variables, registered inregister_cvars/register_commandsincrates/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/.
| Layer | Crates | May depend on |
|---|---|---|
| 0 core | math, kv, console | each other |
| 1 core services | config, vfs, platform | layer 0; each other |
| 2 data and hardware | resource, asset, bsp, walk, map, scene, rhi, material | layers 0–1; each other |
| 3 subsystems | render, physics, anim, audio, entity, script, ui | layers 0–2, not each other |
| 4 lifecycle | lifecycle | layers 0–3 |
| 5 host and game | engine, game | layers 0–4 |
| 6 tools | toolui and every tool | layers 0–5; each other |
| 7 facade | kerosene | everything |
| 8 runtime | kerosene-runtime | everything |
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<dyn Game> 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 ofglam; pub mod gamewithStock, the stock classes plus theGameimpl;pub mod toolsbehind thetoolsfeature, for a game that ships an editor;pub mod prelude, the handful of names most game code names, andVERSION.
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
| Crate | Responsibility | Notable source |
|---|---|---|
kerosene-math | Units, Plane/Winding/Aabb, angles, Pose, epsilon constants | src/units.rs, src/plane.rs, src/winding.rs |
kerosene-kv | KeyValues parse/serialise, typed reads, format_float, the I/O Connection encoding | src/parse.rs, src/value.rs, src/connection.rs |
kerosene-console | ConVars, ConCommands, command buffer, log relay, crash handler | src/lib.rs, src/logging.rs |
kerosene-lifecycle | Modules started in phases and stopped in reverse, managed named threads and the worker Pool, the counting TrackingAllocator | src/manager.rs, src/thread.rs, src/pool.rs, src/mem.rs |
kerosene-config | engine.kcfg with defaults for every key | src/lib.rs, src/renderer.rs |
kerosene-vfs | Search-path stack, .vault archives, content discovery, toolchain | src/lib.rs, src/root.rs, src/archive.rs |
kerosene-resource | The compiled resource container (header, typed blocks, references, source hash), Resource<T> handles and their cache, the asset-type table | src/container.rs, src/handle.rs, src/types.rs |
kerosene-asset | .ktex, .kmdl readers/writers; .kmat source and its compiled .kmat_c | src/texture.rs, src/material.rs, src/model.rs |
kerosene-map | .kmap source, brush ops (clip/carve/hollow), editor metadata | src/solid.rs, src/ops.rs, src/editor.rs |
kerosene-bsp | .kbsp lumps, tree queries, traces, PVS, acoustics, sections | src/lib.rs, src/trace.rs, src/vis.rs |
kerosene-walk | .kwalk walkmap, navigation graph, the per-face WalkmapRule | src/lib.rs, src/nav.rs, src/rule.rs |
kerosene-scene | What 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-rhi | The render hardware interface: opening a GPU, the device and surface, frame read-back | src/gpu.rs, src/surface.rs, src/capture.rs |
kerosene-material | Materials on the GPU: the shared bind layout, loading a material’s maps, texture upload | src/maps.rs, src/texture.rs |
kerosene-physics | Source gamemovement, CollisionWorld trait | src/movement.rs, src/world.rs, src/rigid.rs (Box3D PhysicsWorld) |
kerosene-entity | Entity slots, fields, I/O queue, class registry, schema, save snapshots | src/world.rs, src/io.rs, src/schema.rs, src/snapshot.rs |
kerosene-render | CPU PVS/mesh build, lightmap atlas, dynamic lights, probes, wgpu backend | src/mesh.rs, src/gpu.rs, src/lightmap.rs |
kerosene-anim | skeleton, clip sampling, crossfade, skinning palette | src/lib.rs |
kerosene-audio | ADPCM, mixer, FDN reverb, device output | src/mixer.rs, src/reverb.rs, src/compiled.rs |
kerosene-script | Rhai VM (and the one rhai dependency), world snapshot, ScriptAction queue, the platform object both script VMs register | src/lib.rs, src/view.rs, src/bindings.rs, src/platform.rs |
kerosene-ui | Game UI: XML/CSS/Rhai documents, store and bindings, flexbox, glyph atlas | src/document.rs, src/bind.rs, src/style.rs |
kerosene-platform | The store: Platform (validation, batching, events) over a Backend – Steam behind the steam feature, an offline stand-in otherwise | src/lib.rs, src/steam.rs |
kerosene-toolui | The tools’ egui window host, theme and widgets | src/lib.rs, src/theme.rs |
kerosene-engine | Engine, Game, host, launch, streaming, acoustics glue, saved games | src/engine.rs, src/host.rs, src/save.rs |
kerosene-game | Stock classes: doors, triggers, logic, props, sound | src/doors.rs, src/logic.rs, src/props.rs |
kerosene | Facade, Stock, launch, prelude | src/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 callsEngine::frameonce 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 & +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(&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 >= 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 >= 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_tickruns before movement specifically so a game can change speed, gravity or jump permission before the movement reads the convars (movement_params).game.tickruns after entity I/O but before the rigid-body step. The module doc ingame.rsstates this: “the player has moved, triggers have fired, entities have thought and their requests are answered. Physics props have not yet stepped.”previous_brush_posesis snapshotted beforeentities.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_playermakes props bounce off the player,push_propslets the player move them.player_push_directionreads 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).
| Hook | When | Typical use |
|---|---|---|
classes(&mut ClassRegistry) | once, at engine construction | register entity classes |
schema() -> &'static str | tools only | .kdef text for the inspector |
setup(&mut Engine) | console up, no map loaded | register convars/commands |
map_loaded(&mut Engine) | after entities spawn, player placed, map script ran | per-map state |
pre_tick(&mut Engine, &InputState, dt) | before movement | change movement |
tick(&mut Engine, &InputState, dt) | after I/O, before props step | game rules |
entity_request(&mut Engine, &HostRequest) -> bool | unclaimed entity request | custom host verbs |
console_request(&mut Engine, kind, payload) -> bool | unclaimed console request | custom console verbs |
wants_ui() -> bool | every frame | opt into the UI layer |
ui(&mut Engine, &egui::Context) | when wanted / mouse free | HUD, 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:
real_dtfromInstant, capped at 0.25 s.input.update_view(&console)andinput.state().set_host_paused(console open || background)andset_background(..): the engine decides from those, the pause menu andsv_pause_on_menuwhether it is paused (Engine::is_paused). Paused,frameruns the console and pending loads but no ticks, and empties the accumulator so unpausing is not a burst of catch-up ticks.engine.frame(real_dt, &input_state)— zero or more ticks.- Drain console requests the engine did not claim (host verbs), report the rest.
quit_requested()→ exit.- Rebuild GPU resources if
load_generation()changed (by generation, not name, so reloading a recompiled map shows new geometry). stream_sections()— build wanted sections on worker threads, upload on this thread.draw(real_dt), unless the window is occluded (minimised or covered), when it sleeps 20 ms instead.- 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
emitexactly 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):
- 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.
- 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 = 128caps 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.
| # | Lump | Contents |
|---|---|---|
| 0 | entities | KeyValues text, one entity block each |
| 1 | planes | BspPlane { normal, dist, kind } |
| 2 | vertices | [f32; 3] |
| 3 | edges | Edge { v: [u32; 2] } |
| 4 | surfedges | signed indices into edges; negative = backwards |
| 5 | faces | Face (plane, side, surfedge range, texinfo) |
| 6 | nodes | Node (plane, two children, bounds) |
| 7 | leaves | Leaf (contents, cluster, leafbrush range) |
| 8–9 | leaffaces, leafbrushes | indirection from leaf to faces/brushes |
| 10 | models | Model, one per brush entity plus world (0) |
| 11–12 | brushes, brushsides | convex brushes and their planes |
| 13–15 | texinfo, texdata, texdata_strings | material binding |
| 16 | visibility | RLE PVS/PAS |
| 17 | lighting | ColorRgbExp32 per luxel |
| 18–19 | acoustics, acoustic_leafs | rooms and leaf→room |
| 20–22 | sections, face_sections, brush_sections | streaming |
| 23 | cubemaps | reflection 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_audibleusesVisKind::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 <= 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 & mask == 0"]
test --> clip["clip_to_brush per brush"]
clip --> enter{"enter_frac <= 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 < -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:
| Mask | Includes | Used by |
|---|---|---|
MASK_PLAYER_SOLID | SOLID, MOVEABLE, PLAYER_CLIP, WINDOW, GRATE | player movement |
MASK_SHOT | SOLID, MOVEABLE, WINDOW, GRATE | bullets, pick-up reach |
MASK_OPAQUE | SOLID, MOVEABLE, OPAQUE | line of sight, shadow rays |
MASK_WATER | WATER, SLIME | water level |
MASK_VOLUMES | WATER, SLIME, LADDER | non-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—AcousticRoomper room behind anAcousticHeader, plus oneu16per leaf (NO_ROOMfor 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—SectionRecordper section, oneu16per 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<Option<Entity>>"]
gens["generations: Vec<u32>"]
free["free: Vec<u32>"]
names["by_name: HashMap<lowercase, Vec<EntityId>>"]
queue["queue: BinaryHeap<PendingEvent>"]
reqs["requests: Vec<HostRequest>"]
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<PendingEvent><br/>min fire_at, then sequence")] --> dispatch["dispatch_due()"]
dispatch --> resolve["resolve(Target) → Vec<EntityId>"]
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_thinkand 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
EntityIdheld anywhere (a queued event, a script’s integer, the game’s state) names the same entity after a load, and the nextspawnreturns the handle it would have returned. - Spawn handlers do not run. Running them would re-fire
logic_autoand reset counters. Classes that keep something outside their fields registerClassDef::on_restoreinstead. Onlyambient_genericdoes, to ask for its looping sound again. - Restore is all or nothing. The engine restores into a fresh
EntityWorldand 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<RefCell<Shared>>.actions<br/>Vec<ScriptAction>"]
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-levelprintwould otherwise re-run on every later call and every console line; - loading the same name twice replaces its definitions, so
script_reloaddoes 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.
texinfoproduces 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>>andmodel_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 inWorldMeshrecords 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_lightread the same. A test pins that at 100 units. - Clustered.
LightFrame::buildkeeps the nearestMAX_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²
Depth32Floatarray, 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 withdraw_world_shadow/draw_studio_shadow, culled by the light’s own PVS and frustum –visible_surfacesfrom 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-physicsis a faithful reimplementation of Source’sgamemovement. 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-rigidwraps 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 && on_ground<br/>&& !jump_held && 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 >= Waist<br/>&& !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.zis zeroed and the move isstep_move; in the air, gravity is applied and the move is plaintry_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:
- move straight along the ground (
try_move); - 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::initsets Box3D’s length-unit scale toINCHES_PER_METREonce 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_modelfails are cached so a missing.kmdlis 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_playermakes props bounce off the player;push_propslets the player move them.player_push_directionreads 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;
Enginesteers 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.
| Layer | Source | Responsibility |
|---|---|---|
wav | wav.rs | Source .wav → samples |
compiled + adpcm | compiled.rs, adpcm.rs | .kaud → samples (4 bits/sample) |
mixer | mixer.rs | Voices → stereo buffer, no device |
reverb, env, dsp | reverb.rs, env.rs, dsp.rs | The room and the air |
device | device.rs (feature device) | Buffer → sound card |
engine | crates/kerosene-engine/src/audio.rs | Bank, listener, voice tracking |
acoustics | crates/kerosene-engine/src/acoustics.rs | Room 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:
- 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.
- If the source is inside rock,
Unreachable(but an ear inside rock still hears everything rather than nothing). - 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-ui | Everything up to a list of quads. No GPU, no winit, no engine |
crates/kerosene-render/src/ui.rs, shaders/ui.wgsl, shaders/panel.wgsl | Drawing those quads, on screen and into world panel textures |
crates/kerosene-render/src/decals.rs | Cutting decals out of the world mesh (CPU) |
crates/kerosene-render/src/gpu.rs (GpuDecals, Pass::Decal) | Drawing them |
crates/kerosene-engine/src/ui.rs | The engine’s side: the store, publishing, console commands, world panel entities, the decal list |
crates/kerosene-engine/src/host.rs | Input routing, the menu’s hold on the mouse, the frame’s UI passes |
crates/kerosene-game/src/ui.rs | logic_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-toolui | Not 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:
- Evaluate the bindings whose store keys changed since the last frame
(
UiStore::changed_since,Template::depends_on). - Deliver the store’s events to
on:handlers andon_event. - Run due timers, then the handlers input queued.
- Apply what scripts asked for (
DocOps, store writes,UiActions). - Re-cascade every panel marked dirty, with its subtree.
- Advance transitions and keyframe animations into
Node::shown. - Lay out with taffy. It caches, so an unchanged tree costs almost nothing.
- 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 andUiActions 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 theclass:/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), andread_layoutpasses the parent’s origin through it.- Transitions snapshot
shownwhen a property’s target changes and blend from that snapshot to the new target. Only the properties instyle::AnimPropanimate. 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:
ui_frameruns the documents. This happens inApp::frame, after the ticks.- Upload the glyph atlas and any new images.
- Render world panels into their textures, but only those whose
revisionmoved. - Cut new decals and upload them if the decal revision moved.
- 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.
- Tonemap, then the screen UI, then egui (
Game::uiand 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.rscover each module headless.crates/kerosene-ui/tests/content.rsloads every shipped layout and fails on any warning.crates/kerosene-engine/tests/ui.rsuses a compiled map to test map scripts publishing, damage events,infodecal, thedecalcommand, the pause menu, and a keypad panel (a test fixture of its own) whoseOnUnlockopens a door. The demo’s real keypad is tested in kerosene-demo’stests/keypad.rs.crates/kerosene-render/tests/ui_smoke.rsruns on a real GPU and skips without one. SetKEROSENE_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.rs | Platform: declarations, validation, stat batching, the event queue. PlatformAction and its one-line text form, PlatformEvent, PlatformView |
crates/kerosene-platform/src/null.rs | The 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.rs | SteamBackend, behind the steam feature |
crates/kerosene-script/src/platform.rs | The Rhai platform/steam object, registered by both script VMs |
crates/kerosene-engine/src/platform.rs | Entity requests, result outputs, console commands, publishing to the UI store |
crates/kerosene-game/src/platform.rs | The five logic_* classes |
tools/kiln/src/steam.rs | --ship --steam: build options, finding the redistributable, SteamPipe scripts |
tools/kerosene-tools/src/workshop.rs | workshop 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::PlatformandUiAction::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:
- 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.
- The UI store. The
platform.*keys are republished and the event is emitted. - The map script’s
on_platform_event. 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::tickdispatches what the tick caused.Engine::platform_framepumps 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.rscoversPlatformagainst the null backend and the Rhai object.crates/kerosene-engine/tests/platform.rsuses a compiled map to cover every class’s outputs, map scripts, and cheat gating.tools/kiln/src/ship/tests.rscovers--steamships against a fake build directory.- CI lints the
steamfeature 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 insrc/new/template/: a package, its toolset binary, cargo aliases, a project file and a starter map built withkerosene-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, thentoolchain::resolveto 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.
| Key | Meaning |
|---|---|
name | title-bar name; defaults to the file stem |
content | content tree, relative to the file; falls back to content/ or the file’s directory |
startmap | map to load when nothing else says which (optional) |
game | the Cargo package that is the game; kiln --ship builds it and F9 launches it |
bin | that package’s binary, when not the package’s own name |
dir | repeatable; 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.rsandtools/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.objbecomesmodels/props/crate.kmdl— the path underartis the path undermodels, so a model’s name is decided by where its source is. - Maps: each
.kmapgoes 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:
vaultpacks 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
| Testable | Depends on | Enabled by |
|---|---|---|
| Whole simulation | nothing | Engine has no surface |
| Player movement | a CollisionWorld | the trait, and BoxWorld |
| Entity I/O | EntityWorld | handlers take &mut EntityWorld only |
| Scripts | a WorldView snapshot | scripts return ScriptActions |
| Rendering decisions | Bsp + WorldMesh | mesh.rs/lightmap.rs are CPU-only |
| Shaders | naga | same compiler wgpu uses |
| Audio mixing | buffers | mixer.rs has no device |
| Compilers | plain data | each stage is a pure function |
| The editor | the library | egui 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, andcargo docon 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.