Epitaph Documentation
Epitaph is a bytecode-compiled scripting language for the NetSlum game engine. It replaces standard programming keywords with thematic terminology inspired by .hack, making scripts read like in-world programs. The language is game-agnostic – any project can embed Epitaph for its logic layer.
Table of Contents
| Document | Description |
|---|---|
| Getting Started | Project setup, hello-world game, compile and run |
| Language Reference | Complete syntax, types, control flow, coroutines |
| Standard Library | math, time, color, fmt, json, bytes, string, list |
| Engine API | Graphics, audio, input, save, window, tilemap, ECS |
| Networking | Optional TCP, HTTP, and WebSocket modules |
| Build System | ns CLI, build.toml, game.toml, asset pipeline |
What is NetSlum?
NetSlum is a modular game engine written in Rust. It ships as a collection of
crates that handle rendering, audio, input, scripting, asset packing, and
networking. Games are written primarily in Epitaph scripts (.ep files) that
the engine compiles to bytecode and executes inside a lightweight stack-based VM.
Crate Map
ns (workspace)
├── epitaph Language: lexer, parser, compiler, VM, stdlib
├── ns-core Engine runtime: game loop, rendering, input, scripting host
├── ns-audio Audio subsystem: sink trait, backends (rodio), AudioRuntime plugin
├── ns CLI: compile .ep → .epc, bundle into .pak, scaffold, run
├── ns-assets PAK format, content loading, asset providers
├── ns-world Minimal ECS (sparse sets, dense iteration)
├── ns-network Optional networking: TCP, HTTP, WebSocket
├── ns-epd Epitaph Data format (.epd) for structured game data
├── ns-build-plugin WASI plugin ABI for build-time asset transforms
└── ns-shell-minifb Reference platform shell (minifb window + input, no audio)
How the pieces fit together
┌─────────────┐ ┌───────────┐
│ .ep scripts │──► │ ns │──► scripts.pak (.epc bytecode)
└─────────────┘ │ CLI │──► game.pak (processed assets)
┌─────────────┐ │ │──► game.toml (runtime config)
│ raw assets │──► └───────────┘
└─────────────┘
│
▼
┌──────────────────────────────────────────┐
│ ns-core (Engine) │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ epitaph │ │ ns-assets│ │ns-world│ │
│ │ VM │ │ loaders │ │ ECS │ │
│ └──────────┘ └──────────┘ └────────┘ │
│ ┌──────────┐ │
│ │ ns-audio │ AudioSink (rodio, etc.) │
│ └──────────┘ │
└──────────────────────────────────────────┘
│
▼
┌──────────────┐
│ ns-shell-* │ Platform layer (window + input only)
└──────────────┘
Quick Links
- Install globally:
cargo install --path crates/ns - Scaffold a new project:
ns init my-game - Compile a script:
ns compile main.ep -o main.epc - Pack all scripts:
ns pack scripts/ -o scripts.pak - Full manifest build:
ns build --manifest build.toml - Manifest + Rust binary:
ns build --full(or--full --release) - Run (dev, no pack):
ns run(runscargo runin thebuild.tomldirectory) - Build + run release:
ns run --release - Rust workspace:
ns run -p your-game-crate(andns build --full -p …when using--full)
Getting Started
This guide walks you through creating your first Epitaph game, from an empty directory to a running window.
Prerequisites
- Rust toolchain (stable) – needed to build
nsand your game - ns CLI installed globally:
cargo install --path crates/ns
Scaffold a New Project
The fastest way to start is with ns init:
ns init my-game
cd my-game
This creates a complete project:
my-game/
Cargo.toml Rust project (depends on ns-core, ns-audio, ns-shell-minifb)
build.toml Build manifest for scripts/assets
.gitignore
src/
main.rs Game runner (engine + audio + shell setup)
scripts/
main.ep Entry-point Epitaph script
What gets generated
src/main.rs sets up the engine, audio backend, and shell. It uses
debug vs release the same way as ns-dothack-app: in debug, scripts and
assets load from ./scripts and ./assets when those folders exist; in
release (or when PAKs exist), it uses game.toml paths and scripts.pak /
game.pak.
Audio is wired to the engine (not the shell) via engine.set_audio(...) and
the AudioRuntime script plugin. The shell only handles the window and input.
See the template generated by ns init in crates/ns/src/init.rs (MAIN_RS)
for the full mount_scripts / try_mount_assets helpers using
PackProvider (not PakProvider) and DirProvider.
scripts/main.ep is a minimal hello-world game:
sector game
access <gfx>
access <color>
access <window>
phase init() {
window.open(640, 480, "My Game")
}
phase tick(dt) {
}
phase draw() {
gfx.fill_screen(30, 30, 60)
gfx.draw_text("Hello, NetSlum!", 10, 10, color.White)
}
Note: The generated
Cargo.tomlpullsns-core,ns-audio,ns-assets, andns-shell-minifbfromgithub.com/Toyz/ns. To use a local checkout instead, replace thegit = "..."entries withpath = "../path-to-ns/crates/..."paths.
Project Layout
After running ns build, the project looks like:
my-game/
Cargo.toml
build.toml
game.toml Generated runtime config
scripts.pak Compiled bytecode archive
src/
main.rs
scripts/
main.ep
If you add an [assets] section to build.toml, you also get a game.pak.
Your First Script
Here is a more complete example with input handling:
sector game
access <gfx>
access <color>
access <input>
access <window>
access <sys>
let message = "Hello, NetSlum!"
let x = 80
let y = 60
phase init() {
window.open(640, 480, "My Game")
}
phase tick(dt) {
sustain ev = window.poll_event() {
when ev == :close_requested {
sys.exit()
}
}
let cmd = input.poll()
when cmd == :left { x = x - 2 }
when cmd == :right { x = x + 2 }
when cmd == :up { y = y - 2 }
when cmd == :down { y = y + 2 }
}
phase draw() {
gfx.fill_screen(20, 20, 40)
gfx.draw_text(message, x, y, color.White)
}
What this does
sector game– declares the module name. All phases are qualified asgame.init,game.tick,game.draw.access <gfx>– imports the graphics module so you can callgfx.fill_screen,gfx.draw_text, etc.access <color>– imports color presets likecolor.Whiteand constructors likecolor.rgb(r, g, b),color.rgba(...), andcolor.hex("#rrggbb").phase init()– runs once when the engine starts.phase tick(dt)– runs on a fixed timestep (default 60 Hz). Drainswindow.poll_event()so :close_requested can callsys.exit(), then polls input and moves the text.phase draw()– runs every render frame. Clears the screen and draws the message.
The Game Contract
Every Epitaph game must provide exactly three phases in sector game:
| Phase | Signature | When it runs |
|---|---|---|
game.init | phase init() | Once at startup |
game.tick | phase tick(dt) | Every simulation frame (dt = seconds per tick) |
game.draw | phase draw() | Every render frame |
The engine validates that all three exist before starting. If any is missing, you get a load error.
Important: tick vs draw
The engine uses a fixed-timestep loop:
game.tickruns at a fixed rate (default 60 Hz). Use it for game logic, physics, input handling, and state updates.game.drawruns once per frame after all pending ticks. Use it for rendering.
Only draw commands issued during game.draw appear on screen. Any
gfx.* calls made in init or tick are collected but never rendered.
Compile and Run
Build and run in one step
ns run
Runs cargo run without packing first. In debug, your game loads
.ep files from ./scripts (and ./assets if present) so iteration is fast.
ns run sets Cargo’s working directory to the folder that contains build.toml
(so it works when your shell is elsewhere). If that folder is part of a Cargo
workspace and cargo run would pick the wrong crate, use ns run -p your-package.
ns run --release
Runs ns build (scripts + assets PAKs, game.toml), then cargo run --release.
The release binary reads packed scripts and assets.
Build only
ns build
ns build --full # also run `cargo build` after packing
ns build --full --release # same with `cargo build --release`
ns build --full -p my-game # workspace: `cargo build -p my-game`
Compiles every .ep file under scripts/ into bytecode, bundles them into
scripts.pak, processes assets (if configured), and writes game.toml.
With --full, ns then builds your game crate so the binary matches the new PAKs.
Run separately
cargo run # debug → dev-mode paths (./scripts, ./assets)
cargo run --release # release → PAKs / game.toml paths
The generated src/main.rs picks script and asset sources based on that split
(see ns init template).
Compile a Single Script
For quick iteration you can compile one file without a full build:
ns compile scripts/main.ep -o main.epc
This produces a standalone .epc (Epitaph Pre-Compiled) bytecode file.
Next Steps
- Language Reference – learn the full syntax
- Standard Library – math, strings, bytes, JSON, and more
- Engine API – graphics, audio, input, save, window management
- Build System – asset pipeline, plugins,
game.tomlconfig
Epitaph Language Reference
Epitaph is a bytecode-compiled scripting language for the NetSlum engine. It
uses thematic keywords inspired by .hack – phase instead of function,
when instead of if, active/dormant instead of true/false, and so on.
Pipeline
source (.ep) → Lexer → tokens → Parser → AST → Compiler → Module (bytecode) → VM
Modules can be serialized to .epc (Epitaph Pre-Compiled) files and bundled
into .pak archives via the ns CLI. The engine loads bytecode directly at
runtime – no source parsing needed in release builds.
Comments
Single-line comments start with -- (double dash):
-- this is a comment
let x = 42 -- inline comment
There are no block comments.
Sectors (Modules)
Every .ep file must begin with a sector declaration — it is the module
namespace:
sector game
The sector name qualifies all phases, module-level variables, and codex entries
defined in the file. A phase init() inside sector game becomes game.init
at runtime. Omitting sector is a parse error.
Imports
The access keyword brings in external code:
access "battle_utils" -- file import (loads scripts/battle_utils.ep)
access <math> -- stdlib module (host-backed)
access <string> -- stdlib module (pure Epitaph)
access <vec> -- stdlib module (pure Epitaph, 2D/3D vectors)
access hub.volume_key -- legacy dotted path import
Import kinds
| Syntax | Resolves to |
|---|---|
access "name" | File scripts/name.ep (relative to script root) |
access <name> | Standard library module |
access a.b | Legacy path import (first segment = filename) |
Imports are processed recursively before the current file is compiled, so imported phases and globals are available immediately.
Types
Epitaph has 11 value types:
| Type | Literal | Description |
|---|---|---|
int | 42, -7, 0xFF, 0b1010, 0o77 | 64-bit signed integer |
float | 3.14, 0.5, 42f | 64-bit IEEE 754 float |
bool | active, dormant | Boolean (also accepts true/false) |
text | "hello" | UTF-8 string (ref-counted) |
symbol | :done, :attack(target) | Interned tag with optional payload |
list | [1, 2, 3] | Ordered, growable collection |
map | {"hp": 100, "sp": 50} | Key-value dictionary (insertion-ordered) |
range | 0..10 | Half-open integer range [start, end) |
record | Player() | Fragment instance (named struct) |
bytes | (no literal) | Mutable byte buffer (ref-counted) |
void | void | Null / absence of value |
Truthiness
- Falsy:
dormant(false),0,void, emptybytes - Truthy: everything else
Optional type annotations (gradual)
Types are optional. When present, the compiler checks that values match the hint (single file only; imported callees and host functions are treated as unknown).
| Site | Syntax |
|---|---|
| Phase parameter | name or name: Type |
| Phase return | After ): -> Type then { |
let / module let / fixed | let x: Type = expr |
| Fragment field | name = expr or name: Type = expr |
Type expressions: primitives (int, float, bool, text, symbol,
void, bytes, range), a fragment or codex name declared in the
same script (nominal record / codex tag), and list[Inner] for homogeneous
lists.
Literals
Integers
42 -- decimal
-7 -- negative
0xFF -- hexadecimal (0x or 0X prefix)
0b1010 -- binary (0b or 0B prefix)
0o77 -- octal (0o or 0O prefix)
Binary and octal literals allow underscores for readability: 0b1010_0011.
Floats
3.14 -- decimal point
0.5 -- leading zero required
42f -- float suffix (integer parsed as float)
0b1010f -- binary-to-float (10.0)
0o77f -- octal-to-float (63.0)
The f suffix converts any integer literal (decimal, binary, or octal) to a
float. It is not available on hex literals because f is a valid hex digit.
Booleans
active -- true
dormant -- false
true -- alias for active
false -- alias for dormant
Text (Strings)
"Hello, world!"
"line one\nline two"
"tab\there"
"escaped \" quote"
"backslash \\"
Supported escape sequences: \n, \t, \\, \".
Symbols
Symbols are interned tags used for lightweight enumerations and message passing:
:done
:attack
:confirm
Symbols can carry a payload value:
:damage(25)
:move(target)
The payload is accessible via the .data field on the symbol value.
Lists
let items = [1, 2, 3]
let mixed = ["sword", 42, active]
let empty = []
Access elements by index (zero-based):
items[0] -- 1
items[2] -- 3
Maps
let m = {"hp": 100, "sp": 50}
m["hp"] -- 100
m["hp"] = 80 -- update
Keys can be any expression. Values are accessed with bracket notation.
Ranges
0..10 -- Range from 0 (inclusive) to 10 (exclusive)
Ranges are primarily used with traverse:
traverse i in 0..5 {
-- i = 0, 1, 2, 3, 4
}
Variables
Local variables
let x = 42
let name = "Kite"
x = x + 1 -- reassignment
Variables are block-scoped. A let in an inner block shadows outer names.
Module-level globals
Top-level let statements become globals, initialized when the script loads:
sector game
let score = 0
let level = 1
These are accessible as game.score and game.level from within the sector,
and can be read cross-sector (see Cross-Sector Access).
fixed bindings and fixed phase
fixed name = expr declares a module global like let, but it cannot be
reassigned anywhere (a compile error). The initializer is evaluated at
compile time: it may use literals, arithmetic, comparisons, bitwise int
operators (&, |, ^, <<, >>, ~), symbol
literals with const payloads, calls to fixed phase functions that appear
earlier in the file, and identifiers naming fixed bindings also
declared earlier.
fixed phase name(params) { body } defines a const-safe function. The body
may only use let statements and must end with resolve expr (no when,
inspect, host calls, etc. in the body). The same const-expression rules apply
inside those expressions. At runtime, compiled bytecode is still emitted, so
ordinary phase code may call a fixed phase like any other function.
Order: A fixed initializer or fixed phase body may only reference
fixed phase names and other fixed globals that appear above it in the
source file (no mutual recursion).
Call syntax: Inside const contexts, a call must target a prior fixed phase by a single identifier — e.g. sum(1, 2, 3). Dotted callees such as
menu.items() or other.phase() are not evaluated at compile time and produce
a compile error that points at the callee.
Phases (Functions)
Phases are the fundamental unit of code. They take parameters and optionally
return a value with resolve:
phase greet(name) {
resolve "Hello, " + name
}
phase tick(dt) {
let msg = greet("Kite")
}
A phase without an explicit resolve implicitly returns void.
Phases are called by name. Within the same sector, bare names work:
greet("Kite")
Cross-sector calls use qualified names:
utils.format_hp(current, max)
Fragments (Structs)
Fragments define named record types with default field values:
fragment Player {
hp = 100
name = "Kite"
pos = Transform()
}
fragment Transform {
x = 0
y = 0
}
Create instances by calling the fragment name:
let p = Player()
p.hp = p.hp - 20
p.pos.x = 42
Fields are mutable and support nested access within the same sector that
constructed the instance. At runtime, each fragment value remembers the
owner sector (the sector of the script whose constructor ran). Direct assignment to a field (p.x = 1) is
allowed only while executing code whose chunk belongs to that same owner
sector. Other sectors may still read fields and must route writes through
phases defined in the owner sector (for example owner.helper(p, v) or a
fragment method that runs under the owner’s bytecode), mirroring how
cross-sector module let bindings are read-only to foreign assigners.
Embedding (embed)
embed Other; flattens Other’s fields (and nested embeds) into the record
and promotes its fragment methods (phase Other.foo(self) { ... } becomes
callable as outer.foo() on instances of the outer fragment). Field name
collisions between embeds and explicit fields are compile errors.
Fragment methods
Top-level phases named Fragment.method define methods: the first parameter
must be self. Calls: recv.method(args) or Fragment.method(recv, args) when
Fragment is the type name (not a local/global binding). Resolution is limited
to the current script.
Custom construction (ctor)
Fragment() uses field defaults. For Fragment(e1, e2, …) in the same
script as the fragment, define phase Fragment.ctor(e1, e2, …) with no
self, at least one parameter, returning a new instance (typically let r = Fragment(); …; resolve r). That desugars to sector.Fragment.ctor(…).
Cross-module callers should use a module helper (for example vec.vec2(x, y) after access <vec>) instead of bare Vec2(x, y).
Vectors (access <vec>)
See docs/standard-library.md. Epitaph does not
yet support vector literals (such as v2{1, 2}) or operator overloading
on records (u + v); those would require new language/VM work.
Codex (Enums / Constants)
Codex blocks define named constant groups:
codex State {
boot = 0
title = 1
play = 2
}
codex Assets {
bg_title = "images/bg_title.png"
sfx_beep = 13
}
Access variants with dot notation:
let s = State.boot -- 0
let img = Assets.bg_title -- "images/bg_title.png"
Supported value types: int, float, text, bool, symbol. At runtime,
codex entries are flat globals (State.boot, State.title, etc.).
Spells (removed)
The old spell "Name" { ... } form is not part of the language anymore.
Use a fragment for structured metadata (fields with defaults) and a
phase (or fixed phase) for callable logic with a stable name:
fragment FireballData {
element: symbol = :fire
cost: int = 12
power: int = 45
}
phase Fireball() {
let d = FireballData()
resolve d.power * 2
}
Operators
Arithmetic
| Operator | Description | Notes |
|---|---|---|
+ | Add | Also concatenates text and bytes |
- | Subtract | |
* | Multiply | |
/ | Divide | |
% | Modulo | |
- (unary) | Negate |
Int and float values mix freely – if either operand is a float, the result is a float.
Comparison
| Operator | Description |
|---|---|
== | Equal |
!= | Not equal |
< | Less than |
<= | Less than or equal |
> | Greater than |
>= | Greater than or equal |
Bitwise (int only)
| Operator | Description |
|---|---|
& | Bitwise AND |
| | Bitwise OR |
^ | Bitwise XOR |
<< | Left shift (count masked to 0–63) |
>> | Right shift (arithmetic for signed int) |
~ (unary) | Bitwise NOT |
Logic
| Operator | Description |
|---|---|
and | Logical AND (short-circuiting) |
or | Logical OR (short-circuiting) |
not | Logical NOT |
Precedence (lowest to highest)
| Level | Operators |
|---|---|
| 1 | or |
| 2 | and |
| 3 | ==, != |
| 4 | <, <=, >, >= |
| 5 | | (bitwise or) |
| 6 | ^ (bitwise xor) |
| 7 | & (bitwise and) |
| 8 | <<, >> |
| 9 | .. (range) |
| 10 | +, - |
| 11 | *, /, % |
| 12 | Unary -, not, ~ |
| 13 | Postfix: .field, [index], (call) |
Control Flow
when / otherwise (if / else)
when hp <= 0 {
resolve :defeat
} otherwise {
resolve :continue
}
The otherwise branch is optional.
Else-if chaining — use otherwise when to test additional conditions:
when hp <= 0 {
resolve :defeat
} otherwise when hp < 20 {
log.warn("low hp!")
} otherwise {
resolve :continue
}
You can chain as many otherwise when branches as needed. The final
otherwise block is optional and acts as a catch-all.
Expression form (inline conditional):
let label = when level == 0 { "Start" } otherwise { "Level " + level }
inspect (match)
Pattern-match on a value:
inspect cmd {
:attack => { resolve start_attack() }
:skills => { resolve open_skills() }
:flee => { resolve attempt_flee() }
_ => { log.warn("unknown command") }
}
Arms can be single expressions or blocks. Each arm tests equality against the
scrutinee. The _ => arm matches anything and must be last.
Guard conditions — add when after a pattern to apply an extra condition:
inspect damage_type {
:physical when armor > 0 => {
apply_damage(damage - armor)
}
:physical => { apply_damage(damage) }
:magical => { apply_magic_damage(damage) }
_ => { log.warn("unknown type") }
}
Symbol destructuring — bind a symbol’s payload to a variable in the arm:
inspect event {
:error(msg) => { log.error(msg) }
:ok(data) => { process(data) }
:pending => { wait() }
}
:error(msg) matches any :error(...) symbol and binds the payload to msg.
You can also access the payload with .data (e.g. event.data).
Guards and destructuring can be combined:
inspect result {
:ok(val) when val > 0 => { use_value(val) }
:ok => { log.info("zero or negative") }
:error(e) => { log.error(e) }
}
Expression form — assign the result of inspect to a variable:
let name = inspect id {
0 => "Kite"
1 => "BlackRose"
_ => "Unknown"
}
Guards and destructuring work in expression form too:
let label = inspect status {
:ok(msg) => msg
:error(e) => "ERR: " + e
_ => "unknown"
}
sustain (while)
Loop while a condition is truthy:
let i = 0
sustain i < 10 {
i = i + 1
}
Assign-and-test form – binds a value and loops while it is truthy:
sustain cmd = input.poll() {
inspect cmd {
:left => { x = x - 1 }
:right => { x = x + 1 }
}
}
traverse (for-each)
Iterate over a collection or range:
traverse item in items {
process(item)
}
traverse i in 0..10 {
log.info(i)
}
The in keyword separates the binding from the collection.
Alternative as syntax (binding defaults to it if omitted):
traverse items as item {
process(item)
}
traverse items {
process(it) -- implicit binding name
}
resolve (Return)
Returns a value from a phase:
resolve 42
resolve :done
resolve -- returns void
Execution leaves the current phase immediately.
suspend (Coroutine Yield)
Yields a value from a coroutine and pauses execution:
suspend :waiting
suspend 42
suspend -- yields void
The coroutine can be resumed later by the host, which sends a value back. The
suspend expression evaluates to whatever the host sends on resume.
phase countdown() {
let i = 10
sustain i > 0 {
suspend i -- yield current count
i = i - 1
}
resolve :done
}
From the host (Rust) side:
#![allow(unused)]
fn main() {
let result = vm.call_coroutine("game.countdown", &[]);
// result = Suspended(Int(10))
let result = vm.resume(Value::Void);
// result = Suspended(Int(9))
// ... continue until Completed(:done)
}
Bytes
The bytes type represents a mutable buffer of raw bytes. There is no literal
syntax – buffers are created via the bytes standard library module:
access <bytes>
let buf = bytes.new(16) -- 16 zero-filled bytes
buf[0] = 0xFF
let val = buf[0] -- 255 (as int)
let size = buf.len -- 16
Indexing
- Read:
buf[i]returns the byte at indexias anint(0–255). Out-of-bounds returnsvoid. - Write:
buf[i] = valsets bytei. The value is truncated to au8. Out-of-bounds is a runtime error.
Concatenation
The + operator concatenates two byte buffers into a new one:
let combined = buf_a + buf_b
Neither original buffer is mutated.
Fields
.len– the buffer length in bytes
See the Standard Library for the full bytes module API
including ByteReader and ByteWriter.
Networking (ns-network add-on)
When the engine loads the optional ns-network crate, TCP and WebSocket payloads
are bytes values, not UTF-8 text. net.tcp_send and
net.ws_send_binary accept either text (UTF-8) or bytes.
net.tcp_recv, net.ws_recv, and net.poll_event records with
type: data expose a data field as bytes; use bytes.to_text when
you need a string.
Chunked reads: pass a second int argument for a maximum byte count, e.g.
net.tcp_recv(handle, 1024) returns at most 1024 bytes per call (or void if
nothing is queued). Without the second argument, one queued chunk is returned in
full. The same optional limit applies to net.ws_recv.
poll_event vs direct recv: both drain the same per-connection receive
queue; use one style consistently if you want a single consumer.
After receiving bytes, use bytes.reader, bytes.read_u8, etc.,
or bytes.slice, to parse binary protocols.
Cross-Sector Access
Scripts in different sectors can read each other’s module-level globals:
-- In provider.ep
sector provider
let DATA = 42
let LIST = [1, 2, 3]
-- In consumer.ep
sector consumer
phase test() {
let x = provider.DATA -- reads 42
let y = provider.LIST[1] -- reads 2
}
Writing to another sector’s globals is a compile-time error:
provider.DATA = 99 -- ERROR: cannot assign to cross-sector variable
This keeps module boundaries explicit – sectors can expose read-only data but own their mutation.
Fragment instances follow the same idea: another sector cannot assign to your fragment’s fields directly; use a phase or method in the defining sector to mutate.
First-Class Callables
Phases and host functions can be passed around as values. When you reference a phase name without calling it, you get a text value containing the qualified name. Calling a text value dispatches to the named phase or host function:
let f = greet -- text "game.greet" (if in sector game)
f("Kite") -- calls game.greet("Kite")
Execution Model
- Stack-based bytecode VM – max 256 stack slots, 64 call frames
- Single-threaded, synchronous – no async, no threads within the VM
- No garbage collector – values are stack-allocated or ref-counted (
Rc) - Symbols are interned – O(1) comparison via integer IDs
- Module init – top-level
letand deferred codex values run once when a script is loaded (via an internal__module_initchunk) - Deterministic – same inputs produce the same outputs (except
math.randandtime.*)
Standard Library
The Epitaph standard library provides built-in functions and importable modules for math, time, colors, formatting, JSON, byte manipulation, string utilities, and list operations.
Built-in functions are always available without any import. Modules require
access <name> at the top of your script.
Built-in Functions
These are available everywhere, no access needed.
concat(a, b) -> text
Concatenates two values as text. Non-text values are stringified.
concat("hp: ", 100) -- "hp: 100"
append(list, ...items) -> list
Appends one or more items to a list in place and returns the list.
let items = [1, 2]
append(items, 3, 4) -- items is now [1, 2, 3, 4]
Errors if the first argument is not a list.
len(collection) -> int
Returns the length of a list, text, map, bytes buffer, or range. Returns 0
for other types.
len([1, 2, 3]) -- 3
len("hello") -- 5
len(0..10) -- 10
int_to_text(n) -> text
Converts an integer to its decimal text representation.
int_to_text(42) -- "42"
math
access <math>
Host-backed module for common math operations.
Functions
| Function | Returns | Description |
|---|---|---|
math.floor(n) | int | Floor of n |
math.ceil(n) | int | Ceiling of n |
math.round(n) | int | Round n to nearest integer |
math.round(n, places) | float | Round n to places decimal digits |
math.abs(n) | int/float | Absolute value (preserves type if int) |
math.clamp(v, lo, hi) | int/float | Clamp v to [lo, hi] (int if all args are int) |
math.sqrt(n) | float | Square root |
math.pow(base, exp) | float | base raised to exp |
math.sin(n) | float | Sine (radians) |
math.cos(n) | float | Cosine (radians) |
math.atan2(y, x) | float | Two-argument arctangent |
math.lerp(a, b, t) | float | Linear interpolation: a + (b - a) * t |
math.rand(min, max) | int | Random integer in [min, max] inclusive |
math.rand_float() | float | Random float in [0, 1) |
Constants
| Name | Value |
|---|---|
math.PI | 3.14159265… |
math.TAU | 6.28318530… |
Examples
access <math>
let angle = math.atan2(dy, dx)
let dist = math.sqrt(dx * dx + dy * dy)
let clamped = math.clamp(hp, 0, max_hp)
let roll = math.rand(1, 6)
time
access <time>
Host-backed module for elapsed time queries.
| Function | Returns | Description |
|---|---|---|
time.now() | float | Seconds elapsed since engine start |
time.ms() | int | Milliseconds elapsed since engine start |
Example
access <time>
phase tick(dt) {
let elapsed = time.now()
when elapsed > 5.0 {
log.info("Five seconds have passed")
}
}
color
access <color>
Host-backed module for creating and using color records.
Functions
| Function | Returns | Description |
|---|---|---|
color.rgb(r, g, b) | record | Color with alpha 255. Args default to 0 if missing. |
color.rgba(r, g, b, a) | record | Color with explicit alpha. a defaults to 255. |
color.hex(s) | record | Parse a CSS-style hex string into the same Color record. |
color.hex accepts:
#RRGGBBorRRGGBB— alpha 255#RRGGBBAAorRRGGBBAA— includes alpha#RGBorRGB— each digit is expanded (e.g.#f0a→ red 255, green 0, blue 170)#RGBAorRGBA— short form with alpha
Leading # is optional; surrounding whitespace is trimmed. Invalid length or digits produce a runtime error.
All constructors return a Color record with fields r, g, b, a (all ints, 0–255).
Preset Colors
| Name | RGBA |
|---|---|
color.White | 255, 255, 255, 255 |
color.Black | 0, 0, 0, 255 |
color.Red | 255, 0, 0, 255 |
color.Green | 0, 255, 0, 255 |
color.Blue | 0, 0, 255, 255 |
color.Yellow | 255, 255, 0, 255 |
color.Cyan | 0, 255, 255, 255 |
color.Magenta | 255, 0, 255, 255 |
color.Transparent | 0, 0, 0, 0 |
Example
access <color>
access <gfx>
let sky = color.rgb(100, 149, 237)
gfx.fill_screen(sky)
let same_sky = color.hex("#6495ed")
gfx.fill_screen(same_sky)
let semi = color.rgba(255, 0, 0, 128)
gfx.fill_rect(10, 10, 50, 50, semi)
let semi_hex = color.hex("#ff000080")
gfx.fill_rect(60, 10, 50, 50, semi_hex)
fmt
access <fmt>
Host-backed module for text formatting.
Functions
| Function | Returns | Description |
|---|---|---|
fmt.str(v) | text | Convert any value to text. Text passes through; others are stringified. |
fmt.join(items, sep?) | text | Join a list into text with sep (default ""). Non-list → empty text. |
fmt.format(template, ...args) | text | Replace {} placeholders with args in order. |
Examples
access <fmt>
fmt.str(42) -- "42"
fmt.join(["a", "b", "c"], ", ") -- "a, b, c"
fmt.format("HP: {}/{}", hp, max_hp) -- "HP: 80/100"
Extra {} placeholders with no matching argument are left as literal {}.
json
access <json>
Host-backed module for JSON encoding and decoding.
Functions
| Function | Returns | Description |
|---|---|---|
json.decode(text) | value | Parse JSON text into an Epitaph value. Returns void on error. |
json.encode(value) | text | Compact JSON string. |
json.encode_pretty(value) | text | Pretty-printed JSON string. |
Type mapping
| Epitaph | JSON |
|---|---|
int, float | number |
bool | boolean |
text | string |
void | null |
list | array |
map, record | object |
symbol | string (:id format) |
bytes | array of ints |
range | [start, end] |
Example
access <json>
let data = json.decode("{\"name\": \"Kite\", \"level\": 5}")
let name = data["name"] -- "Kite"
let out = json.encode({"hp": 100, "items": [1, 2, 3]})
string
access <string>
Pure Epitaph module for string utilities.
| Function | Returns | Description |
|---|---|---|
string.pad_left(s, width, ch) | text | Left-pad s to width with character ch |
string.pad_right(s, width, ch) | text | Right-pad s to width with character ch |
string.repeat(s, count) | text | Repeat s count times |
Examples
access <string>
string.pad_left("42", 5, "0") -- "00042"
string.pad_right("hi", 10, ".") -- "hi........"
string.repeat("ab", 3) -- "ababab"
list
access <list>
Pure Epitaph module for list utilities.
| Function | Returns | Description |
|---|---|---|
list.contains(items, target) | bool | active if target is in items |
list.reverse(items) | list | New list with elements in reverse order |
Examples
access <list>
list.contains([1, 2, 3], 2) -- active
list.contains([1, 2, 3], 9) -- dormant
list.reverse([1, 2, 3]) -- [3, 2, 1]
vec
access <vec>
Pure Epitaph 2D / 3D vectors (fragment Vec2, fragment Vec3 with float
x, y, z). After importing, build values with the module helpers (not
Vec2(x, y) from another sector — that sugar only applies in the same file as
the fragment):
| Function | Returns | Description |
|---|---|---|
vec.vec2(x, y) | Vec2 | New vector |
vec.vec3(x, y, z) | Vec3 | New vector |
Instance / static methods (same naming as other fragments):
| Call | Returns | Description |
|---|---|---|
u.add(v) / Vec2.add(u, v) | Vec2 / Vec3 | Component-wise add |
u.sub(v) | Vec2 / Vec3 | Component-wise subtract |
u.scale(s) | Vec2 / Vec3 | Multiply each component by s |
u.dot(v) | num | Dot product |
u.len_sq() | num | Squared Euclidean length |
u.cross(v) (Vec3 only) | Vec3 | Cross product |
Examples
access <vec>
let u = vec.vec2(3.0, 4.0)
let v = vec.vec2(1.0, 0.0)
let w = Vec2.add(u, v) -- static call; same-sector code can use u.add(v)
let d = u.x + u.y -- 7.0
bytes
access <bytes>
Host-backed module for creating and manipulating binary data. Provides buffer constructors, slicing, encoding/decoding, and structured reader/writer APIs.
Buffer Creation
| Function | Returns | Description |
|---|---|---|
bytes.new(size) | bytes | Zero-filled buffer of size bytes. Default 0. |
bytes.from_list(list) | bytes | Buffer from a list of ints (each truncated to u8) |
bytes.to_list(buf) | list | List of ints, one per byte |
bytes.slice(buf, start?, end?) | bytes | Sub-buffer. Bounds are clamped. Defaults: start=0, end=len. |
bytes.from_text(text) | bytes | UTF-8 encoded bytes |
bytes.to_text(buf) | text | Decode UTF-8. Errors if bytes are not valid UTF-8. |
Examples
access <bytes>
let buf = bytes.new(4)
buf[0] = 72 -- 'H'
buf[1] = 105 -- 'i'
let greeting = bytes.from_text("Hello")
let back = bytes.to_text(greeting) -- "Hello"
let nums = bytes.from_list([10, 20, 30])
let mid = bytes.slice(nums, 1, 3) -- [20, 30]
ByteReader
A cursor-based reader for structured binary data. Reads advance the position automatically.
Creating a Reader
let reader = bytes.reader(buf)
Returns a record with fields buf (the byte buffer) and pos (current read
position, starts at 0).
Read Functions
All read functions take the reader as the first argument and advance pos.
Functions with a _le suffix read in little-endian byte order. Without a suffix,
the default is big-endian.
| Function | Returns | Description |
|---|---|---|
bytes.read_u8(r) | int | Read 1 byte (0–255) |
bytes.read_i16(r) | int | Read signed 16-bit integer (big-endian) |
bytes.read_i16_le(r) | int | Read signed 16-bit integer (little-endian) |
bytes.read_i32(r) | int | Read signed 32-bit integer (big-endian) |
bytes.read_i32_le(r) | int | Read signed 32-bit integer (little-endian) |
bytes.read_i64(r) | int | Read signed 64-bit integer (big-endian) |
bytes.read_i64_le(r) | int | Read signed 64-bit integer (little-endian) |
bytes.read_f32(r) | float | Read 32-bit float (big-endian) |
bytes.read_f32_le(r) | float | Read 32-bit float (little-endian) |
bytes.read_f64(r) | float | Read 64-bit float (big-endian) |
bytes.read_f64_le(r) | float | Read 64-bit float (little-endian) |
bytes.read_bytes(r, n) | bytes | Read n raw bytes |
bytes.read_text(r, n) | text | Read n bytes as UTF-8 text |
bytes.remaining(r) | int | Bytes left to read |
Reads error at runtime if there aren’t enough bytes remaining.
Example
access <bytes>
let data = bytes.from_list([0, 42, 0, 1, 0, 100])
let r = bytes.reader(data)
let header = bytes.read_u8(r) -- 0
let id = bytes.read_u8(r) -- 42
let value = bytes.read_i32(r) -- 65636 (big-endian)
let left = bytes.remaining(r) -- 0
ByteWriter
A growable buffer writer for building binary data.
Creating a Writer
let writer = bytes.writer()
Returns a record with a buf field (initially empty bytes buffer).
Write Functions
All write functions take the writer as the first argument and append bytes.
Functions with a _le suffix write in little-endian byte order. Without a
suffix, the default is big-endian.
| Function | Args | Description |
|---|---|---|
bytes.write_u8(w, val) | int | Write 1 byte (truncated to u8) |
bytes.write_i16(w, val) | int | Write signed 16-bit (big-endian) |
bytes.write_i16_le(w, val) | int | Write signed 16-bit (little-endian) |
bytes.write_i32(w, val) | int | Write signed 32-bit (big-endian) |
bytes.write_i32_le(w, val) | int | Write signed 32-bit (little-endian) |
bytes.write_i64(w, val) | int | Write signed 64-bit (big-endian) |
bytes.write_i64_le(w, val) | int | Write signed 64-bit (little-endian) |
bytes.write_f32(w, val) | float | Write 32-bit float (big-endian) |
bytes.write_f32_le(w, val) | float | Write 32-bit float (little-endian) |
bytes.write_f64(w, val) | float | Write 64-bit float (big-endian) |
bytes.write_f64_le(w, val) | float | Write 64-bit float (little-endian) |
bytes.write_bytes(w, src) | bytes | Append raw bytes |
bytes.write_text(w, text) | text | Append UTF-8 bytes |
All write functions return void.
Example
access <bytes>
let w = bytes.writer()
bytes.write_u8(w, 0x01)
bytes.write_i32(w, 1000)
bytes.write_text(w, "OK")
let result = w.buf -- the built buffer
let size = result.len -- 7 (1 + 4 + 2)
Roundtrip Example
access <bytes>
-- Write structured data
let w = bytes.writer()
bytes.write_u8(w, 1) -- version
bytes.write_i32(w, 320) -- width
bytes.write_i32(w, 240) -- height
bytes.write_text(w, "hello") -- payload
-- Read it back
let r = bytes.reader(w.buf)
let version = bytes.read_u8(r) -- 1
let width = bytes.read_i32(r) -- 320
let height = bytes.read_i32(r) -- 240
let payload = bytes.read_text(r, 5) -- "hello"
Engine API
The engine registers host function modules that scripts use to interact with
graphics, audio, input, events, persistence, windowing, and more. Each module is
imported with access <name> and exposes functions via dot notation
(e.g. gfx.fill_screen).
sys
access <sys>
System-level operations.
| Function | Args | Returns | Description |
|---|---|---|---|
sys.warp(target) | symbol or text | void | Request a scene/mode transition. Symbols become "sym:id". The target is placed into side effects for the engine or runner to act on. |
sys.exit() | – | void | Request engine shutdown. The game loop exits after the current tick completes. |
sys.is_headless() | – | bool | active if the process was built with EngineConfig::headless, else dormant. Same source of truth as the headless / tick-only driver (no separate ns-network dependency). |
sys.runtime_role() | – | symbol | :offline_singleplayer, :client, or :server, from EngineConfig::runtime_role at startup; immutable for the process. |
Example
access <sys>
when game_over {
sys.warp(:title_screen)
}
when should_quit {
sys.exit()
}
log
access <log>
Logging to stderr with severity prefixes.
| Function | Args | Returns | Description |
|---|---|---|---|
log.info(...) | variadic | void | Log with [ep:info] prefix |
log.warn(...) | variadic | void | Log with [ep:warn] prefix |
log.error(...) | variadic | void | Log with [ep:error] prefix |
log.debug(...) | variadic | void | Log with [ep:debug] prefix |
Arguments are formatted and joined with spaces.
Example
access <log>
log.info("Player HP:", hp, "/ ", max_hp)
log.warn("Low health!")
log.error("Failed to load asset:", path)
input
access <input>
Input handling via a command queue. The engine pushes input events as symbols each tick. Scripts consume them by polling.
| Function | Args | Returns | Description |
|---|---|---|---|
input.poll() | – | symbol or void | Pop the next input command from the queue. Returns void if empty. |
input.peek() | – | symbol or void | View the next command without consuming it. |
input.held(key) | text | bool | Whether key is currently held down. |
input.count() | – | int | Number of queued commands. |
input.bind(key, command) | text, text | void | Bind a key name to a command name. |
input.unbind(key) | text | void | Remove a key binding. |
Example
access <input>
phase tick(dt) {
sustain cmd = input.poll() {
inspect cmd {
:left => { x = x - speed * dt }
:right => { x = x + speed * dt }
:confirm => { do_action() }
}
}
when input.held("shift") {
speed = run_speed
}
}
Key Bindings
Key bindings map physical key names to command symbols. They can be set in
game.toml under [keys] or at runtime via input.bind:
input.bind("ArrowLeft", "left")
input.bind("ArrowRight", "right")
input.bind("z", "confirm")
input.bind("x", "cancel")
Input vs event queues
The input queue is filled with key-edge commands and cleared by the engine after each tick batch so edges do not carry across frames. Use it only for device-derived commands.
The event queue is a separate FIFO for game or engine signals. It stores
full values—usually symbols, optionally with a metadata payload (the same
Symbol(id, payload) shape as in the language). Entries stay queued until a
script polls them or calls event.clear(); the engine does not
flush this queue at tick boundaries.
From Rust, call EpitaphRuntime::push_event, push_event_value,
push_event_payload, and clear_event_queue (see
crates/ns-core/src/scripting.rs).
event
access <event>
Game/engine event mailbox: a FIFO of values (typically :tag or :tag(data)).
Unlike input, this queue is not cleared automatically each tick.
| Function | Args | Returns | Description |
|---|---|---|---|
event.poll() | – | value or void | Remove and return the next event. void if empty. |
event.peek() | – | value or void | Next event without removing it. |
event.count() | – | int | Number of queued events. |
event.clear() | – | void | Discard all queued events. |
event.emit(tag) | symbol | void | Enqueue that symbol (including any payload on the literal, e.g. :err("msg")). |
event.emit(tag, meta) | symbol, any | void | Enqueue tag with meta as the symbol payload (second form overwrites a literal payload). |
Unknown symbol names passed to Rust push_event / push_event_payload are
silently ignored, matching input command behavior.
Example
access <event>
phase tick(dt) {
sustain ev = event.poll() {
inspect ev {
:door_open(id) => { open_door(id) }
:quest_update => { refresh_quest_ui() }
_ => {}
}
}
}
mouse
access <mouse>
Pointer state for the current outer display frame (one snapshot per loop
iteration, before the tick batch). Coordinates are in buffer pixels (the
same space as the framebuffer passed to the shell). Logical coordinates
match gfx / engine.screen_*: mouse.logical_x() ≈ mouse.x() / engine.scale().
| Function | Args | Returns | Description |
|---|---|---|---|
mouse.x() | – | float | Horizontal position in buffer pixels. |
mouse.y() | – | float | Vertical position in buffer pixels. |
mouse.logical_x() | – | float | X in logical screen units. |
mouse.logical_y() | – | float | Y in logical screen units. |
mouse.inside() | – | bool | Whether the pointer maps inside the buffer rectangle. |
mouse.down(name) | text | bool | "left", "right", or "middle" held. |
mouse.pressed(name) | text | bool | Same names; true only on the frame the button went down. |
mouse.scroll_x() | – | float | Horizontal scroll delta this frame (platform units). |
mouse.scroll_y() | – | float | Vertical scroll delta this frame. |
The reference ns-shell-minifb shell maps the OS
cursor into buffer space using the window size vs buffer size.
gamepad
access <gamepad>
Connected pads appear in stable gamepad-id order; index i runs from 0
to gamepad.count() - 1 when using ns-shell-minifb with the gamepad Cargo
feature enabled. Without that feature, gamepad.count() is always 0.
| Function | Args | Returns | Description |
|---|---|---|---|
gamepad.count() | – | int | Number of gamepad slots in the current snapshot. |
gamepad.connected(i) | int | bool | Whether slot i has a connected controller. |
gamepad.axis(i, name) | int, text | float | Stick/trigger axis: lx, ly, rx, ry, lt, rt (triggers 0..1). |
gamepad.down(i, name) | int, text | bool | Named button held (see table below). |
gamepad.pressed(i, name) | int, text | bool | Button edge this frame. |
Button names (gilrs / Xbox-style layout): a, b, x, y, lb, rb,
lt_btn, rt_btn, select, start, guide, lstick, rstick, up, down,
left, right.
Enable gamepads in your game Cargo.toml:
ns-shell-minifb = { path = "../ns/crates/ns-shell-minifb", features = ["gamepad"] }
gfx
access <gfx>
Graphics rendering. All draw calls queue render commands that are executed
during game.draw. Draw calls made during game.init or game.tick are
not rendered.
Color Arguments
Many gfx functions accept colors in two forms:
- Color record: A record with
r,g,b,afields (fromcolor.rgb,color.rgba, orcolor.hex). - Three integers:
r, g, bas separate arguments with alpha defaulting to 255.
Functions
| Function | Args | Returns | Description |
|---|---|---|---|
gfx.fill_screen(color) | color record, or r, g, b | void | Fill the entire screen with a solid color. |
gfx.fill_rect(x, y, w, h, color) | ints + color record, or x, y, w, h, r, g, b | void | Fill a rectangle. |
gfx.draw_text(text, x, y, color?, font?, scale?) | see below | void | Draw text at position. Optional scale is dot size in buffer pixels (int or float, may be fractional). |
gfx.draw_image(handle, x, y, img_scale?) | int, int, int, optional int/float | void | Draw a loaded image at logical (x, y). Optional img_scale: buffer pixels per source pixel for this draw only (default: engine scale). Clamped to 1..64. Logical placement still uses engine scale; a smaller img_scale draws a smaller bitmap (e.g. engine scale 4 and img_scale 2 halves pixel footprint vs omitting the argument). |
gfx.draw_image_crop(handle, x, y, sx, sy, sw, sh, img_scale?) | 7 ints + optional int/float | void | Cropped blit; optional 8th img_scale same semantics as draw_image. |
gfx.push_clip(x, y, w, h) | 4 ints | void | Push a clipping rectangle onto the stack (logical coords, same as fill_rect; multiplied by engine scale to buffer pixels). Intersects with the current clip. |
gfx.pop_clip() | – | void | Pop the most recent clipping rectangle. On flush, the active clip limits fill_rect, draw_text, draw_image, draw_image_crop, and draw_tilemap; fill_screen is not clipped. |
gfx.screen_width() | – | int | Logical screen width. |
gfx.screen_height() | – | int | Logical screen height. |
gfx.set_font(font) | symbol | void | Set the default font. :small for small font, :normal for normal. |
gfx.load_font(path) | string | bool | Load a .epf packed font from the raw asset store (mount .epf files or register bytes). Lookup matches assets.load_image–style lazy resolution: exact path, then path + ".epf" when path does not already end in .epf. Returns true on success. Returns false (falsy) if the argument is missing, not a string, empty, no raw entry matches, or decode fails (decode errors are logged); the active font is unchanged in those cases. |
gfx.reset_font() | – | void | Clear the loaded .epf font and use the built-in bitmap font again. |
gfx.set_text_outline(color?) | color record, r,g,b, or false | void | Enable a 1-pixel cardinal outline (shadow) behind all subsequent draw_text calls. Pass false or no arguments to disable. The outline is drawn at 4 offsets (up/down/left/right) in the given color before the foreground glyph. |
gfx.set_text_edges(mode?) | symbol or nothing | void | Controls how alpha .epf glyphs are drawn (1-bit fonts are always crisp). :crisp — threshold alpha (≥128) to solid pixels, no soft fringe. :smooth — alpha blending (default behavior for alpha fonts). :auto or no arguments — follow the font (alpha → smooth, 1-bit → crisp). Requires symbols crisp, smooth, and auto to be registered (use :crisp, etc. in scripts). |
Custom .epf fonts affect gfx.draw_text glyph rendering and advances. Kerning and complex scripts are not supported—monospace-style bitmap cells only.
Scripting: gfx is a host global (gfx.load_font, …), not a script-backed access module. Avoid a fragment gfx or a top-level phase gfx.… whose name collides with a host call (e.g. phase gfx.load_font): the compiler always binds gfx.load_font(...) to the engine host so calls do not disappear as “void” or wrong chunks.
Build .epf files with the ns:font-builder built-in plugin (ns build rules or ns asset --plugin ns:font-builder). See docs/build-system.md and docs/font-epf.md.
draw_text Details
gfx.draw_text(text, x, y) -- white text, default font
gfx.draw_text(text, x, y, 255, 200, 0) -- colored text (r, g, b)
gfx.draw_text(text, x, y, my_color) -- color record
gfx.draw_text(text, x, y, 255, 255, 255, :small) -- with font symbol
gfx.draw_text(text, x, y, 255, 255, 255, :normal, 2) -- integer dot size
gfx.draw_text(text, x, y, 255, 255, 255, :normal, 1.5) -- fractional dot size
Arguments after position are optional:
- color: record or three ints (default: 255, 255, 255)
- font:
:smallor:normalsymbol (default: lastset_fontor normal) - scale: positive int or float — dot size in buffer pixels per glyph cell (default: engine UI scale). Fractional values (e.g.
1.5) are supported.
Example
access <gfx>
access <color>
phase draw() {
gfx.fill_screen(20, 20, 40)
gfx.fill_rect(10, 10, 100, 20, color.Red)
gfx.draw_text("Score: " + score, 12, 12, color.White)
gfx.draw_text("GAME OVER", 100, 100, 255, 50, 50, :normal, 2)
}
audio
access <audio>
Audio playback provided by the ns-audio crate. Two pieces must be wired in
your main.rs:
- Backend — an [
AudioSink] implementation that decodes and plays sound.ns-audioships a ready-made rodio backend behind therodiofeature (on by default). Pass it to the engine withengine.set_audio(...). If no backend is set the engine defaults to silent no-op. - Script plugin —
AudioRuntimeregisters theaudio.*andassets.load_soundhost functions. Install it withengine.rt.install_audio(AudioRuntime::new()).
The platform shell has no audio knowledge — audio is entirely owned by the engine.
#![allow(unused)]
fn main() {
// in main.rs
engine.set_audio(ns_audio::AudioBackend::open()); // rodio backend
engine.rt.install_audio(ns_audio::AudioRuntime::new()); // script API
}
Sound files placed in your asset directories (.wav, .ogg, etc.) are
automatically available by path after mount_assets — no manual load_data
step required.
| Function | Args | Returns | Description |
|---|---|---|---|
audio.play_sfx(src, loop?) | int handle or string path, optional bool | int | Play a sound effect. Accepts a handle from assets.load_sound or a path string. Returns a playback handle for later control. |
audio.play_bgm(src, loop?) | int handle or string path, optional bool | void | Play background music. loop defaults to true. |
audio.play(src, loop?) | int handle or string path, optional bool | int | Convenience alias — plays a sound effect. Returns a playback handle. |
audio.stop_bgm() | – | void | Stop background music. |
audio.stop(handle) | int | void | Stop a specific sound by playback handle. |
audio.stop_all() | – | void | Stop all playing sounds. |
audio.set_volume(level) | float | void | Set master volume (0.0 – 1.0). |
Loading sounds
When the audio plugin is installed, it adds load_sound to the assets record:
| Function | Args | Returns | Description |
|---|---|---|---|
assets.load_sound(path) | string | int | Load a sound from the mounted raw assets and return a handle. Returns 0 if the path is not found. Subsequent calls with the same path return the cached handle. |
Example
access <assets>
access <audio>
let bgm = assets.load_sound("sound/overworld.wav")
let sfx = assets.load_sound("sound/hit.wav")
phase init() {
audio.play_bgm(bgm)
}
phase tick(dt) {
when attack_landed {
audio.play_sfx(sfx)
}
// Or play directly by path (convenience)
// audio.play("sound/hit.wav")
}
assets
access <assets>
Asset loading from the mounted content providers (PAK files or directories).
Engine::mount_assets (see crates/ns-core/src/engine.rs) pre-registers known extensions:
.png / .qoi as textures, .epd / .json / .toml as structured data (same keys as paths with the suffix stripped), and everything else as opaque bytes in the raw store.
Format resolution (load_image / load_data)
When you pass an optional second format argument (text, or a symbol whose name was fixed at the last cache_symbols / finalize pass), it always wins over the path extension and over magic-byte detection.
Otherwise the engine uses, in order:
- File extension on
path(e.g..png,.qoi,.epd,.json,.toml). - Magic sniff — images only: PNG signature and QOI
qoif. There is no JSON sniff; use.jsonorload_data(path, "json").
If extension and magic disagree and you did not pass an explicit format, image decode fails (handle 0). That avoids guessing.
Lazy loading: If an image or data entry was not pre-registered at mount, load_image / load_data read from the raw store. They try the exact path, then helpful suffixes: images also try path + ".png" / path + ".qoi"; data tries path + ".epd" / path + ".json" / path + ".toml".
| Function | Args | Returns | Description |
|---|---|---|---|
assets.load_data(path, format?) | text, optional format | value or void | Return structured data for path if already mounted or decode lazily from raw bytes. Built-in formats: epd, json, toml. Returns void if missing or decode fails. |
assets.load_image(path, format?) | text, optional format | int | Return a texture handle: fast path if the image was mounted at startup; otherwise decode from raw bytes (PNG, QOI, or a custom format registered from Rust). Returns 0 if missing or decode fails. |
assets.image_width(handle) | int | int | Width of a loaded image in pixels. Returns 0 for invalid handles. |
assets.image_height(handle) | int | int | Height of a loaded image in pixels. Returns 0 for invalid handles. |
Custom decoders (Rust)
Games can register named decoders that scripts select with the second argument:
EpitaphRuntime::register_data_decoder("myfmt", |bytes| { ... })→assets.load_data(path, "myfmt")EpitaphRuntime::register_image_decoder("myimg", |bytes| { ... })(with theimagefeature) →assets.load_image(path, "myimg")
Format names are compared case-insensitively.
Example
access <assets>
access <gfx>
let bg_handle = 0
phase init() {
bg_handle = assets.load_image("images/background.png")
}
phase draw() {
gfx.draw_image(bg_handle, 0, 0)
-- Smaller on-screen footprint than default (per-draw `img_scale`; engine scale unchanged):
-- gfx.draw_image(bg_handle, 0, 0, 2)
}
save
access <save>
Structured persistence using named slots. Requires a SaveProvider to be
installed in the engine (e.g. file-based or in-memory).
| Function | Args | Returns | Description |
|---|---|---|---|
save.write(slot, ...values) | text, variadic | void | Save values to a named slot. |
save.read(slot) | text | list | Load saved values. Returns empty list if slot doesn’t exist. |
save.delete(slot) | text | void | Delete a saved slot. |
save.exists(slot) | text | bool | Check if a slot has saved data. |
Example
access <save>
phase save_game() {
save.write("slot1", player.hp, player.level, player.name)
}
phase load_game() {
when save.exists("slot1") {
let data = save.read("slot1")
player.hp = data[0]
player.level = data[1]
player.name = data[2]
}
}
window
access <window>
Window management for the platform shell.
Control Functions
| Function | Args | Returns | Description |
|---|---|---|---|
window.open(w, h, title) | int, int, text | void | Open/resize the window. |
window.close() | – | void | Close the window. |
window.set_size(w, h) | int, int | void | Resize the window. |
window.set_title(title) | text | void | Set the window title. |
window.set_fullscreen(on?) | bool | void | Toggle fullscreen. |
window.set_vsync(on?) | bool | void | Toggle vsync. |
window.set_resizable(on?) | bool | void | Toggle resizable. |
window.set_frame_rates(tick_hz, render_hz) | int, int | void | Set simulation tick rate and display refresh hint together (one shell update). Same effect as engine.set_tick_rate + engine.set_render_rate. |
Query Functions
| Function | Returns | Description |
|---|---|---|
window.width() | int | Current window width. |
window.height() | int | Current window height. |
window.is_fullscreen() | bool | Whether the window is fullscreen. |
window.is_focused() | bool | Whether the window has focus. |
window.was_resized() | bool | Whether the window was resized this frame. |
Events
| Function | Returns | Description |
|---|---|---|
window.poll_event() | symbol, text, or bool | Pop one window event. Returns dormant if no events. Events: :resize, :focus, :blur, :close_requested. |
Example
access <window>
phase init() {
window.open(800, 600, "My Game")
window.set_resizable(active)
}
phase tick(dt) {
sustain evt = window.poll_event() {
inspect evt {
:close_requested => { sys.exit() }
:resize => { log.info("Resized to", window.width(), "x", window.height()) }
}
}
}
engine
access <engine>
Engine configuration for tick rate, render rate, scale, and screen size.
VM stack limits (Rust EngineConfig only)
When you build Engine::new with EngineConfig, set vm_stack_max and vm_frames_max for the Epitaph VM operand stack and call-frame depth. Defaults match the historical caps (256 stack slots, 64 frames). Values are clamped to safe ranges via VmLimits::new (stack roughly 64..1 048 576, frames 8..4096). The stack Vec is pre-reserved to the stack cap (size_of::<Value>() × cap) — raise the cap only if scripts need it and memory is acceptable.
Headless / tick-only driver (Rust Engine only)
For servers, CI, or batch simulation you often want fixed game.tick steps without a window, minifb, or full-size framebuffer.
- Set
EngineConfig.headless: true.Engine::newthen allocates no pixel buffer (bufferis empty;tw/thstay 0). Logicalscreen_w/screen_hin config are unchanged for scripts (and forengine.screen_width()/engine.screen_height()from Epitaph once hosts are registered). - Use
NoopHeadlessShell(or your ownPlatformShellstub) withEngine::run: the main loop still accumulates real time, but it never runsgame.draw, never flushes draw lists to the buffer, and never callspresent_buffer. - For exactly N ticks without a wall-clock outer loop, call
Engine::run_headless_ticksafterload_entry. It runs the same bootstrap asrun(on_init,game.init,on_ready), then N times: input poll (empty onNoopHeadlessShell), at most one coroutine resume, onegame.tick, side-effect dispatch, and audio drain — still no draw. It returnsErrifheadlessis not set. Engine::bootstrap_for_runispubif you need init-only from Rust without enteringrun.
Alternative (fully custom): drive EpitaphRuntime yourself: mount scripts, finalize, then call run_phase("game.tick", …) in your own loop and register any hosts your phases touch. The Engine path above keeps window/audio/input wiring consistent with the client loop.
Runtime role & single script tree (Rust EngineConfig + Epitaph)
For one gameplay codebase shared by a headed client and a headless server, keep a single script mount per process and vary only the binary’s Rust config (see networking.md):
EngineConfig.runtime_role—RuntimeRole:OfflineSingleplayer(default, unchanged single-player),Client, orServer. Set when constructingEngineConfigbeforeEngine::new; not meant to change at runtime.EngineConfig.headless— composes with the section above; scripts read it viasys.is_headless()(see sys).
Epitaph exposes both on the sys module (always installed on EpitaphRuntime; see the sys table for sys.is_headless() and sys.runtime_role()). That keeps process profile separate from ns-network, which only registers net.* when you install it.
Advanced: EpitaphRuntime::with_vm_limits_and_options takes RuntimeBuildOptions (headless + runtime_role) if you construct the runtime without Engine.
Assets: point both binaries at the same asset directory or PAK when simulation and client must agree on paths — Engine::mount_assets is keyed by path string; divergent mounts mean missing textures or desynced data on one side.
Optional pattern: per-binary [constants] in game.toml (still one script tree, two [[bin]] targets) if you prefer compile-time flags over runtime_role; avoid inventing ad-hoc private host names for the same branching.
Control Functions
| Function | Args | Returns | Description |
|---|---|---|---|
engine.set_tick_rate(hz) | int | void | Set the simulation tick rate (default 60). |
engine.set_render_rate(hz) | int | void | Set the render frame rate. |
engine.set_scale(n) | int | void | Set the pixel scale factor. |
engine.set_screen_size(w, h) | int, int | void | Set the logical screen dimensions. |
To change tick and render rate in one call from scripts, prefer
window.set_frame_rates(tick_hz, render_hz); the engine.set_tick_rate /
engine.set_render_rate pair remains for changing a single value.
Query Functions
| Function | Returns | Description |
|---|---|---|
engine.tick_rate() | int | Current tick rate in Hz. |
engine.render_rate() | int | Current render rate in Hz. |
engine.scale() | int | Current pixel scale. |
engine.screen_width() | int | Logical screen width. |
engine.screen_height() | int | Logical screen height. |
Example
access <engine>
phase init() {
engine.set_screen_size(320, 240)
engine.set_scale(2)
engine.set_tick_rate(30)
}
tilemap
access <tilemap>
Hex/tile grid rendering and queries.
Setup
The collision/tile grid is a square N×N cells (default 40). Call tilemap.set_grid_size(n) before load_grid_from_asset when your asset is a different size; resizing clears the grid to zeros. Query the current edge length with tilemap.grid_size(). Maximum n is 1024 (values above that are clamped).
Tile texture helpers (load_atlas, set_atlas_item) are only available when the engine is built with the image feature (default in ns-core).
| Function | Args | Returns | Description |
|---|---|---|---|
tilemap.set_grid_size(n) | int / float | void | Set map edge length to n cells (clamped to 1..1024). Reallocates storage to n×n and zeros it. |
tilemap.grid_size() | – | int | Current map edge length in cells. |
tilemap.load_grid_from_asset(path, flush?) | text, optional bool / symbol / text | bool | Load a grid from raw asset bytes (row-major, grid_size × grid_size bytes). Optional second argument selects flush (zero grid before copy): use active, symbol :flush (name flush in the symbol table), or text "flush" / "active" / "true" / "1". Omit, dormant, or any other symbol name → overlay only (legacy). Other truthy values (e.g. non-zero int) still count as flush. Returns whether the asset existed. |
tilemap.load_atlas(prefix, count) | text, int | int | Bind tile indices 0..count-1 to textures. Resolves each slot in order: existing image handle for {prefix}/{i}.png, .qoi, or exact {prefix}/{i}; if missing, lazy-decodes from the raw store (same rules as assets.load_image: .png / .qoi / extensionless + magic). Decoded tiles are registered under {prefix}/{i}.png for caching. Missing or bad slots get handle 0 (skipped when drawing). Returns count. |
tilemap.set_atlas_item(id, src) | int, int or text | void | Set atlas slot id without rebuilding the whole atlas. src is either a texture handle from assets.load_image (including 0 to clear the slot), or an asset path string resolved like a single load_atlas entry (path.png / .qoi / extensionless + raw + decode). Grows atlas_handles with leading 0s if id is past the end. |
tilemap.set_cell_size(px) | int | void | Set cell size in pixels. |
tilemap.set_view_tiles(n) | int | void | Set view radius in tiles. |
tilemap.set_terrain_cap(cap) | int | void | Set terrain passability threshold. |
Camera
| Function | Args | Returns | Description |
|---|---|---|---|
tilemap.snap_camera(q, r) | int/float, int/float | void | Instantly move camera to cell. |
tilemap.follow_camera(q, r, dt) | int/float, int/float, float | void | Smoothly move camera toward cell. |
Queries
| Function | Args | Returns | Description |
|---|---|---|---|
tilemap.is_passable(q, r) | int, int | bool | Whether a cell is passable. |
tilemap.get_cell(q, r) | int, int | int | Tile index at cell. |
tilemap.cell_to_screen_x(q) | int/float | int | Screen X for cell coordinate. |
tilemap.cell_to_screen_y(r) | int/float | int | Screen Y for cell coordinate. |
Rendering
| Function | Args | Returns | Description |
|---|---|---|---|
tilemap.draw(ox, oy) | int, int | void | Draw the tilemap with pixel offset. |
Example
access <tilemap>
access <gfx>
phase init() {
tilemap.set_grid_size(64)
tilemap.load_grid_from_asset("maps/world.grid", :flush)
tilemap.load_atlas("tiles/terrain", 16)
tilemap.set_cell_size(32)
tilemap.set_view_tiles(8)
}
phase tick(dt) {
tilemap.follow_camera(player_q, player_r, dt)
}
phase draw() {
gfx.fill_screen(0, 0, 0)
tilemap.draw(0, 0)
}
ecs
Entity Component System for managing game objects. Host functions are registered
but access <ecs> may not be fully wired in all engine versions – call the
functions directly by name if needed.
| Function | Args | Returns | Description |
|---|---|---|---|
ecs.spawn() | – | int | Create a new entity. Returns entity ID, or -1 if no ECS adapter. |
ecs.destroy(id) | int | void | Remove an entity. |
ecs.set(id, comp, value?) | int, text, value | void | Set a named component on an entity. Value defaults to void. |
ecs.get(id, comp) | int, text | value | Get a component value. Returns void if not found. |
ecs.remove(id, comp) | int, text | void | Remove a component from an entity. |
ecs.query(mask) | text | list | Query entities matching a component mask. Returns list of entity IDs. |
Example
let player_id = ecs.spawn()
ecs.set(player_id, "position", {"x": 100, "y": 50})
ecs.set(player_id, "health", 100)
let pos = ecs.get(player_id, "position")
log.info("Player at", pos["x"], pos["y"])
let entities = ecs.query("health")
traverse entities as id {
let hp = ecs.get(id, "health")
when hp <= 0 {
ecs.destroy(id)
}
}
config
access <config>
Runtime constants loaded from game.toml’s [constants] section. The config
global is a record whose fields match the TOML keys.
game.toml
[constants]
title = "My Game"
version = 3
debug = false
Usage
access <config>
phase init() {
window.set_title(config.title)
when config.debug {
log.info("Debug mode enabled, version", config.version)
}
}
Networking
The ns-network crate provides optional TCP, HTTP, and WebSocket support for
Epitaph scripts. It is opt-in – the game’s Rust runner must install the
network runtime on the engine before scripts can use these functions.
All networking functions live under the net module:
access <net>
Networking is asynchronous – connections and requests run on background threads. Scripts poll for results each tick rather than blocking.
Single script tree, two binaries
ORPG-style games should use one script tree (one directory or one scripts.pak) per title, not parallel scripts/client/ and scripts/server/ mounts that must be kept in sync. Each process still has exactly one Engine::mount_scripts / entry load: ship two Cargo binaries (for example game-client and game-server) that both point at the same on-disk script pack or folder, and set EngineConfig::runtime_role (Client vs Server) plus EngineConfig::headless on the server. Branch in Epitaph with sys.runtime_role() and sys.is_headless() (from ns-core, not this crate; see engine-api.md and Runtime role & single script tree).
Use the same asset pack or paths.assets directory for both binaries whenever rules and art must match: Engine::mount_assets registers the same keys in each process so assets.load_image and raw paths resolve identically.
scripts/ (or scripts.pak) assets/ (or game.pak)
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ │ │ │
client server client server
binary binary binary binary
Shared Functions
These work across all protocol types.
| Function | Args | Returns | Description |
|---|---|---|---|
net.status(handle) | int | text or void | Connection status string. Returns void for unknown handles. |
net.poll_event() | – | record or void | Pop the next network event. Returns void if no events. |
net.close(handle) | int | void | Close any connection or cancel a request. |
Status Values
| Protocol | Possible statuses |
|---|---|
| TCP / WebSocket | "connecting", "connected", "error", "closed" |
| HTTP | "building", "pending", "done", "error" |
Event Records
Events returned by net.poll_event() are records with a type field (text)
and a handle field (int). Some events include additional data:
| Event type | Extra fields | Description |
|---|---|---|
"connected" | – | TCP/WS connection established |
"data" | data (text) | TCP data received |
"message" | data (text) | WebSocket message received |
"accepted" | data (int) | New client handle from TCP listener |
"http_done" | – | HTTP request completed |
"error" | data (text) | Error message |
"closed" | – | Connection closed |
Example: Event Loop Pattern
access <net>
access <log>
phase tick(dt) {
sustain evt = net.poll_event() {
inspect evt.type {
"connected" => { log.info("Connected:", evt.handle) }
"data" => { log.info("Received:", evt.data) }
"error" => { log.error("Error:", evt.data) }
"closed" => { log.info("Closed:", evt.handle) }
}
}
}
TCP
Feature-gated behind tcp in the ns-network crate.
Client Functions
| Function | Args | Returns | Description |
|---|---|---|---|
net.tcp_connect(host, port) | text, int | int | Start an async TCP connection. Returns a handle immediately. |
net.tcp_send(handle, data) | int, text | bool or text | Send UTF-8 data. Returns active on success, or an error message string. |
net.tcp_recv(handle) | int | text or void | Pop next received data chunk. Returns void if nothing queued. |
net.tcp_close(handle) | int | void | Close the connection. |
Server Functions
| Function | Args | Returns | Description |
|---|---|---|---|
net.tcp_listen(port) | int | int | Start listening on a port. Returns a server handle. |
net.tcp_accept(handle) | int | int or void | Accept next pending client. Returns client handle or void. |
net.tcp_stop(handle) | int | void | Stop the listener. |
Example: TCP Client
access <net>
access <log>
let conn = 0
phase init() {
conn = net.tcp_connect("127.0.0.1", 8080)
}
phase tick(dt) {
sustain evt = net.poll_event() {
inspect evt.type {
"connected" => {
log.info("Connected to server")
net.tcp_send(conn, "hello\n")
}
"data" => {
log.info("Server says:", evt.data)
}
"error" => {
log.error("Connection error:", evt.data)
}
}
}
}
Example: TCP Server
access <net>
access <log>
let server = 0
let clients = []
phase init() {
server = net.tcp_listen(9000)
log.info("Listening on port 9000")
}
phase tick(dt) {
sustain evt = net.poll_event() {
inspect evt.type {
"accepted" => {
append(clients, evt.data)
log.info("Client connected:", evt.data)
net.tcp_send(evt.data, "welcome\n")
}
"data" => {
log.info("Client", evt.handle, "says:", evt.data)
}
"closed" => {
log.info("Client disconnected:", evt.handle)
}
}
}
}
HTTP
Feature-gated behind http in the ns-network crate.
Request Lifecycle
- Build a request with
net.http_requestor a convenience function - Optionally set headers with
net.http_set_header - Send the request (automatic for convenience functions)
- Poll for the response via
net.http_response
Request Functions
| Function | Args | Returns | Description |
|---|---|---|---|
net.http_request(method, url) | symbol/text, text | int | Create a request. Method is a symbol (:get, :post, etc.) or text. Returns handle. Does not send yet. |
net.http_request_body(method, url, body) | symbol/text, text, text | int | Create a request with a body. Does not send yet. |
net.http_set_header(handle, name, value) | int, text, text | void | Set a header on an unsent request. |
net.http_set_headers(handle, headers) | int, map | void | Set multiple headers from a map. |
net.http_send(handle) | int | void | Send the request (runs in background). |
Convenience Functions
These create and immediately send a request in one call:
| Function | Args | Returns | Description |
|---|---|---|---|
net.http_get(url) | text | int | GET request |
net.http_head(url) | text | int | HEAD request |
net.http_delete(url) | text | int | DELETE request |
net.http_options(url) | text | int | OPTIONS request |
net.http_post(url, body) | text, text | int | POST request with body |
net.http_put(url, body) | text, text | int | PUT request with body |
net.http_patch(url, body) | text, text | int | PATCH request with body |
Response Functions
| Function | Args | Returns | Description |
|---|---|---|---|
net.http_response(handle) | int | record or void | Get the response. Returns void while pending. On success: record with status (int), body (text), headers (map). On failure: record with error (text). |
net.http_response_header(handle, name) | int, text | text or void | Get a specific response header (case-insensitive). |
Example: Simple GET
access <net>
access <log>
access <json>
let req = 0
phase init() {
req = net.http_get("https://api.example.com/data")
}
phase tick(dt) {
when req != 0 {
let resp = net.http_response(req)
when resp != void {
when resp.status == 200 {
let data = json.decode(resp.body)
log.info("Got data:", data)
} otherwise {
log.error("HTTP error:", resp.status)
}
req = 0
}
}
}
Example: POST with Headers
access <net>
access <json>
let req = net.http_request(:post, "https://api.example.com/submit")
net.http_set_header(req, "Content-Type", "application/json")
net.http_set_header(req, "Authorization", "Bearer my-token")
net.http_send(req)
WebSocket
Feature-gated behind ws in the ns-network crate.
| Function | Args | Returns | Description |
|---|---|---|---|
net.ws_connect(url) | text | int | Open a WebSocket connection. Returns handle. |
net.ws_send(handle, text) | int, text | bool or text | Send a text message. Returns active on success. |
net.ws_send_binary(handle, data) | int, text | bool or text | Send binary data (text bytes). Returns active on success. |
net.ws_recv(handle) | int | text or void | Pop next received message. Returns void if empty. |
net.ws_close(handle) | int | void | Close the WebSocket. |
Example: WebSocket Chat
access <net>
access <log>
let ws = 0
phase init() {
ws = net.ws_connect("wss://chat.example.com/room")
}
phase tick(dt) {
-- handle connection events
sustain evt = net.poll_event() {
inspect evt.type {
"connected" => {
log.info("WebSocket connected")
net.ws_send(ws, "Hello, room!")
}
"message" => {
log.info("Chat:", evt.data)
}
"error" => {
log.error("WS error:", evt.data)
}
"closed" => {
log.info("WebSocket closed")
ws = 0
}
}
}
}
Notes
- All handle-based operations are safe to call with stale handles – they
return
voidor no-op gracefully. - Network operations never block the game loop. Always poll for results in
game.tick. - The
net.poll_event()queue is shared across all protocols. Useevt.handleto identify which connection an event belongs to. - Feature flags (
tcp,http,ws) control which protocol implementations are compiled intons-network. Functions for disabled protocols will not be registered.
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.