Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Making a game

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

Warning

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

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

Two shapes of project

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

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

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

The key that turns one into the other is game.

The project file

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

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

The content tree

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

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

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

The loop

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

Day to day, in a game crate:

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

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

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

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

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

A game crate

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

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

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

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

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

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

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

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

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

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

The game

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What a game most often reaches for:

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

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

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

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

The schema

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

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

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

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

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

The tools

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

// src/tools.rs
mod game;

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

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

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

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