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:
- Compiles and packs all scripts (if
[scripts]is configured) - Processes and packs all assets (if
[assets]is configured) - Writes/updates
game.tomlwith 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]
| Field | Type | Default | Description |
|---|---|---|---|
dir | string | required | Directory containing .ep files |
output | string | "scripts.pak" | Output PAK filename |
[assets]
Specifies where raw assets come from. Use one of:
| Field | Type | Description |
|---|---|---|
dir | string | Single directory of assets |
dirs | list of strings | Multiple asset directories |
jar | string | Path to a ZIP/JAR archive of assets |
output | string | Output PAK filename (default "game.pak") |
[[plugin]]
Declares a named plugin. Each plugin must specify exactly one source:
| Field | Type | Description |
|---|---|---|
name | string | Plugin name (referenced in rules) |
wasm | string | Path to a WASM module (requires wasi feature) |
lua | string | Path to a Lua script (requires lua feature) |
rhai | string | Path to a Rhai script (requires rhai feature) |
ep | string | Path to an Epitaph script (always available) |
[[rule]]
Rules match assets by glob pattern and define how they are processed:
| Field | Type | Description |
|---|---|---|
glob | string | Glob pattern to match asset paths |
action | string | Either "skip" (exclude) or "passthrough" (include as-is) |
plugin | string | Plugin name to transform the asset |
prefix | string | Optional 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:
| Name | Description |
|---|---|
ns:data-pack | Converts TOML files to .epd (Epitaph Data) binary format |
ns:font-builder | Builds .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: eitherbuiltin = true(no TTF; emitspath.epf), orttf = "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 sidecarpath.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--globto filter (e.g.**/*.toml).-o/--output— File or directory. If-iis a single file and-ois a directory, outputs use the plugin’s output basename (e.g.foo.toml→foo.epd). If-iis a directory, relative output paths from the plugin are preserved under-o.--manifest— Required when--pluginis notns:*: loads[[plugin]]entries from the givenbuild.tomlso WASM/Lua/Rhai/Ep paths resolve the same way asns build.
Plugin Runtimes
ns supports four plugin runtimes:
| Runtime | Source field | Feature flag | Description |
|---|---|---|---|
| Epitaph | ep | (always available) | Write transforms in Epitaph |
| WASM | wasm | wasi | Sandboxed WebAssembly modules |
| Lua | lua | lua | Lua 5.4 scripts |
| Rhai | rhai | rhai | Rhai 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
wasifeature on thensCLI: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]
| Field | Type | Description |
|---|---|---|
assets | string | Path to the asset PAK file |
scripts | string | Path 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 compilefor single-file checks during development. The fullns buildis 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.