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

Build System

ns is the NetSlum CLI for compiling Epitaph scripts, processing assets, scaffolding projects, and running games. It supports multiple plugin runtimes for asset transforms and produces the PAK archives and game.toml config that the engine loads at runtime.


Installation

cargo install --path crates/ns

This installs ns globally so you can run it from any project directory.


CLI Commands

init

Scaffold a new game project:

ns init my-game                         # creates my-game/ directory
ns init                                 # scaffolds in current directory

Generates Cargo.toml, src/main.rs, build.toml, scripts/main.ep, and .gitignore. See the Getting Started guide for details.

compile

Compile a single Epitaph script to bytecode:

ns compile script.ep                    # writes script.epc
ns compile script.ep -o out.epc         # custom output path

Produces a .epc (Epitaph Pre-Compiled) file containing the serialized module bytecode and symbol table.

pack

Compile all .ep files under a directory and bundle them into a PAK archive:

ns pack scripts/                        # writes scripts.pak
ns pack scripts/ -o game.pak            # custom output path

Each .ep file is compiled and stored as {relative_path}.epc inside the PAK. File imports (access "name") are resolved and compiled in dependency order.

build

Run a full manifest-driven build:

ns build                                # uses ./build.toml
ns build --manifest path/to/build.toml
ns build --full                         # manifest build, then `cargo build`
ns build --full --release               # same, then `cargo build --release`
ns build --full -p my-game             # workspace: `cargo build -p my-game`

This command:

  1. Compiles and packs all scripts (if [scripts] is configured)
  2. Processes and packs all assets (if [assets] is configured)
  3. Writes/updates game.toml with output paths

--full: after those steps, runs cargo build with current_dir set to the directory that contains build.toml (usually the game crate root). Use -p package-name (and optional --bin name) when that directory is a Cargo workspace root or the game is a workspace member so Cargo knows which package to build. The --release flag only applies together with --full.

run

Run the game binary with cargo run:

ns run                                  # dev: `cargo run` (no pack step)
ns run --release                        # `ns build` then `cargo run --release`
ns run --manifest path/to/build.toml
ns run -p my-game                       # Rust workspace: `cargo run -p my-game`
ns run --bin my-game --manifest crates/my-game/build.toml

Working directory: ns run always runs Cargo with current_dir set to the parent of build.toml. That way cargo run sees the correct Cargo.toml even when you invoke ns from a repo root.

Rust workspaces: if the manifest directory is a virtual workspace root (multiple members, no default binary) or cargo run would pick the wrong crate, pass -p <package> (same as Cargo). Use --bin <name> when the package has several binaries.

Dev (ns run): does not run ns build. Your src/main.rs should load .ep sources from ./scripts when built in debug (see ns init template). This matches the pattern used in ns-dothack-app.

Release (ns run --release): runs a full ns build first, then cargo run --release. The release binary loads scripts.pak / game.pak (or paths from game.toml) instead of raw directories.


build.toml

The build manifest defines what to compile and how to process assets. Place it at your project root.

Full Example

[scripts]
dir = "scripts"
output = "scripts.pak"

[assets]
dir = "assets"
output = "game.pak"

[[plugin]]
name = "my-transform"
ep = "plugins/transform.ep"

[[plugin]]
name = "data-packer"
wasm = "plugins/data_packer.wasm"

[[rule]]
glob = "**/*.toml"
plugin = "ns:data-pack"

[[rule]]
glob = "**/*.font.toml"
plugin = "ns:font-builder"

[[rule]]
glob = "**/*.png"
action = "passthrough"

[[rule]]
glob = "temp/**"
action = "skip"

[[rule]]
glob = "maps/**/*.grid"
plugin = "my-transform"
prefix = "grids"

Sections

[scripts]

FieldTypeDefaultDescription
dirstringrequiredDirectory containing .ep files
outputstring"scripts.pak"Output PAK filename

[assets]

Specifies where raw assets come from. Use one of:

FieldTypeDescription
dirstringSingle directory of assets
dirslist of stringsMultiple asset directories
jarstringPath to a ZIP/JAR archive of assets
outputstringOutput PAK filename (default "game.pak")

[[plugin]]

Declares a named plugin. Each plugin must specify exactly one source:

FieldTypeDescription
namestringPlugin name (referenced in rules)
wasmstringPath to a WASM module (requires wasi feature)
luastringPath to a Lua script (requires lua feature)
rhaistringPath to a Rhai script (requires rhai feature)
epstringPath to an Epitaph script (always available)

[[rule]]

Rules match assets by glob pattern and define how they are processed:

FieldTypeDescription
globstringGlob pattern to match asset paths
actionstringEither "skip" (exclude) or "passthrough" (include as-is)
pluginstringPlugin name to transform the asset
prefixstringOptional path prefix for output files

A rule must have either action or plugin, not both.

Rules are evaluated in order. The first matching rule wins.


Plugin System

Built-in Plugins

Plugins with the ns: prefix are built-in and require no [[plugin]] declaration:

NameDescription
ns:data-packConverts TOML files to .epd (Epitaph Data) binary format
ns:font-builderBuilds .epf bitmap fonts from *.font.toml (and optional TTF sidecar), *.ttf / *.otf, or builtin = true manifests (serializes the engine’s built-in font for tests)

ns:font-builder inputs

  • path.font.toml — TOML root: either builtin = true (no TTF; emits path.epf), or ttf = "Relative.ttf" plus optional [normal] / [small] tables (cell_w, cell_h, line_logical, default_adv, space_adv, raster_px). The TTF path is resolved next to the manifest file.
  • path.ttf / path.otf — Rasterizes a default ASCII (+ NBSP) charset. Without a sidecar path.font.toml, cell sizes and raster px are auto-detected from the font at 8 px (normal) / 6 px (small) so pixel fonts produce correct glyphs out of the box. A sidecar with [normal] / [small] tables overrides the auto-detection.

See docs/font-epf.md for the .epf v1 byte layout.

Ad-hoc: ns asset

Run the same transform as a build rule without producing a full PAK:

ns asset --plugin ns:data-pack -i ./data/player.toml -o ./out/
ns asset --plugin ns:font-builder -i ./assets/fonts -o ./build/fonts/ --glob "**/*.font.toml"
  • -i / --input — File or directory. Directories are walked recursively; use --glob to filter (e.g. **/*.toml).
  • -o / --output — File or directory. If -i is a single file and -o is a directory, outputs use the plugin’s output basename (e.g. foo.tomlfoo.epd). If -i is a directory, relative output paths from the plugin are preserved under -o.
  • --manifest — Required when --plugin is not ns:*: loads [[plugin]] entries from the given build.toml so WASM/Lua/Rhai/Ep paths resolve the same way as ns build.

Plugin Runtimes

ns supports four plugin runtimes:

RuntimeSource fieldFeature flagDescription
Epitaphep(always available)Write transforms in Epitaph
WASMwasmwasiSandboxed WebAssembly modules
LualualuaLua 5.4 scripts
RhairhairhaiRhai scripts

To enable optional runtimes, build with the corresponding feature:

cargo install --path crates/ns --features lua,rhai

Plugin Contract

All plugins implement the same transform contract regardless of runtime:

transform(path: text, data: bytes) -> list of {path: text, data: bytes}
  • Input: The asset’s relative path and raw file contents
  • Output: A list of output entries, each with a path and data

A plugin can:

  • Pass through: Return the input unchanged
  • Transform: Modify the data (e.g. compress, convert format)
  • Split: Return multiple output entries from one input
  • Filter: Return an empty list to drop the asset

WASM Plugin Example

WASM plugins use the Component Model via the WIT interface defined in ns-build-plugin/wit/transform.wit:

package ns:build@0.1.0;

world transform-plugin {
    record asset-entry {
        path: string,
        data: list<u8>,
    }
    export transform: func(input: asset-entry) -> list<asset-entry>;
}

To create a WASM plugin:

1. Create a new crate

cargo new --lib my-plugin
cd my-plugin

2. Set up Cargo.toml

[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.36"

3. Copy the WIT file

Copy crates/ns-build-plugin/wit/transform.wit into my-plugin/wit/.

4. Implement the plugin (src/lib.rs)

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "transform-plugin",
    path: "wit/transform.wit",
});

struct MyPlugin;

impl Guest for MyPlugin {
    fn transform(input: AssetEntry) -> Vec<AssetEntry> {
        // Example: rename .dat → .bin, pass data through
        vec![AssetEntry {
            path: input.path.replace(".dat", ".bin"),
            data: input.data,
        }]
    }
}

export!(MyPlugin);
}

5. Build for WASI

cargo build --target wasm32-wasip2 --release

The output is at target/wasm32-wasip2/release/my_plugin.wasm.

6. Reference in build.toml

[[plugin]]
name = "my-plugin"
wasm = "plugins/my_plugin.wasm"

[[rule]]
glob = "**/*.dat"
plugin = "my-plugin"

Note: WASM plugins require the wasi feature on the ns CLI: cargo install --path crates/ns --features wasi

Epitaph Plugin Example

-- plugins/uppercase.ep
phase transform(path, data) {
    access <bytes>
    let text = bytes.to_text(data)
    -- simple example: uppercase all text files
    let result = bytes.from_text(text)
    resolve [{"path": path, "data": result}]
}

Lua Plugin Example

-- plugins/transform.lua
function transform(path, data)
    return {{ path = path, data = data }}
end

Rhai Plugin Example

// plugins/transform.rhai
fn transform(path, data) {
    [#{ path: path, data: data }]
}

Asset Pipeline

When ns build processes assets, it follows this flow:

 For each file in the asset source(s):
   1. Normalize the path (lowercase, forward slashes)
   2. Match against [[rule]] entries in order
   3. First match determines the action:
      - skip     → file is excluded from the output
      - passthrough → file is copied as-is into the PAK
      - plugin   → file is passed through the named plugin
   4. Plugin output entries are written to the PAK
      (with optional prefix prepended to paths)

Files that match no rule are included as-is (passthrough by default).

PAK Format

Output PAKs use the NSPK format:

  • Magic bytes: NSPK
  • Hash-indexed entries (FNV-1a 64-bit) for fast lookup
  • A __manifest__ entry listing all asset paths
  • Blob section with raw file data

The engine reads PAKs via AssetProvider implementations that support random-access lookups by normalized path.


game.toml

game.toml is the runtime configuration file. ns build automatically creates or updates it with output paths. You can also add game-specific configuration.

Full Example

[paths]
assets = "game.pak"
scripts = "scripts.pak"

[keys]
ArrowUp = "up"
ArrowDown = "down"
ArrowLeft = "left"
ArrowRight = "right"
z = "confirm"
x = "cancel"
Escape = "menu"

[constants]
title = "My Game"
version = 1
debug = false
starting_level = 1

Sections

[paths]

FieldTypeDescription
assetsstringPath to the asset PAK file
scriptsstringPath to the scripts PAK file

These are written automatically by ns build based on the manifest outputs.

[keys]

Default key bindings. Maps physical key names to command names that scripts receive via input.poll():

[keys]
ArrowLeft = "left"
z = "confirm"

Scripts can override bindings at runtime with input.bind().

[constants]

Arbitrary key-value pairs available to scripts via access <config>:

[constants]
title = "My Game"
debug = false

Scripts access these as fields on the config global:

access <config>
let title = config.title     -- "My Game"

Compilation Pipeline

Scripts

 .ep source files
       │
       ▼
 Lexer → Parser → AST → Compiler → Module (bytecode)
       │
       ▼
 Serializer → .epc files
       │
       ▼
 PackWriter → scripts.pak

The compiler processes files in dependency order – if main.ep does access "utils", then utils.ep is compiled first. A shared compiler instance ensures symbol IDs are consistent across all files.

Assets

 Raw files (images, data, audio, etc.)
       │
       ▼
 Rule matching (build.toml [[rule]] entries)
       │
       ├─── skip → excluded
       ├─── passthrough → copied as-is
       └─── plugin → transform(path, data)
                          │
                          ▼
                    Output entries
       │
       ▼
 PackWriter → game.pak

Tips

  • Iterate quickly: Use ns compile for single-file checks during development. The full ns build is for producing release artifacts.
  • Epitaph plugins are free: Unlike WASM/Lua/Rhai, Epitaph plugins require no feature flags and no external tooling. They are a good default for simple transforms.
  • Rule order matters: Rules are evaluated top-to-bottom. Put more specific globs before catch-all patterns.
  • Check your globs: Use "**/*.png" for recursive matching, "*.png" for the root directory only.