Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

DocumentDescription
Getting StartedProject setup, hello-world game, compile and run
Language ReferenceComplete syntax, types, control flow, coroutines
Standard Librarymath, time, color, fmt, json, bytes, string, list
Engine APIGraphics, audio, input, save, window, tilemap, ECS
NetworkingOptional TCP, HTTP, and WebSocket modules
Build Systemns 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)
 └──────────────┘

  • 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 (runs cargo run in the build.toml directory)
  • Build + run release: ns run --release
  • Rust workspace: ns run -p your-game-crate (and ns 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 ns and 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.toml pulls ns-core, ns-audio, ns-assets, and ns-shell-minifb from github.com/Toyz/ns. To use a local checkout instead, replace the git = "..." entries with path = "../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

  1. sector game – declares the module name. All phases are qualified as game.init, game.tick, game.draw.
  2. access <gfx> – imports the graphics module so you can call gfx.fill_screen, gfx.draw_text, etc.
  3. access <color> – imports color presets like color.White and constructors like color.rgb(r, g, b), color.rgba(...), and color.hex("#rrggbb").
  4. phase init() – runs once when the engine starts.
  5. phase tick(dt) – runs on a fixed timestep (default 60 Hz). Drains window.poll_event() so :close_requested can call sys.exit(), then polls input and moves the text.
  6. 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:

PhaseSignatureWhen it runs
game.initphase init()Once at startup
game.tickphase tick(dt)Every simulation frame (dt = seconds per tick)
game.drawphase 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.tick runs at a fixed rate (default 60 Hz). Use it for game logic, physics, input handling, and state updates.
  • game.draw runs 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

Epitaph Language Reference

Epitaph is a bytecode-compiled scripting language for the NetSlum engine. It uses thematic keywords inspired by .hackphase 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

SyntaxResolves to
access "name"File scripts/name.ep (relative to script root)
access <name>Standard library module
access a.bLegacy 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:

TypeLiteralDescription
int42, -7, 0xFF, 0b1010, 0o7764-bit signed integer
float3.14, 0.5, 42f64-bit IEEE 754 float
boolactive, dormantBoolean (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)
range0..10Half-open integer range [start, end)
recordPlayer()Fragment instance (named struct)
bytes(no literal)Mutable byte buffer (ref-counted)
voidvoidNull / absence of value

Truthiness

  • Falsy: dormant (false), 0, void, empty bytes
  • 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).

SiteSyntax
Phase parametername or name: Type
Phase returnAfter ): -> Type then {
let / module let / fixedlet x: Type = expr
Fragment fieldname = 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

OperatorDescriptionNotes
+AddAlso 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

OperatorDescription
==Equal
!=Not equal
<Less than
<=Less than or equal
>Greater than
>=Greater than or equal

Bitwise (int only)

OperatorDescription
&Bitwise AND
|Bitwise OR
^Bitwise XOR
<<Left shift (count masked to 0–63)
>>Right shift (arithmetic for signed int)
~ (unary)Bitwise NOT

Logic

OperatorDescription
andLogical AND (short-circuiting)
orLogical OR (short-circuiting)
notLogical NOT

Precedence (lowest to highest)

LevelOperators
1or
2and
3==, !=
4<, <=, >, >=
5| (bitwise or)
6^ (bitwise xor)
7& (bitwise and)
8<<, >>
9.. (range)
10+, -
11*, /, %
12Unary -, not, ~
13Postfix: .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 index i as an int (0–255). Out-of-bounds returns void.
  • Write: buf[i] = val sets byte i. The value is truncated to a u8. 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 let and deferred codex values run once when a script is loaded (via an internal __module_init chunk)
  • Deterministic – same inputs produce the same outputs (except math.rand and time.*)

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

FunctionReturnsDescription
math.floor(n)intFloor of n
math.ceil(n)intCeiling of n
math.round(n)intRound n to nearest integer
math.round(n, places)floatRound n to places decimal digits
math.abs(n)int/floatAbsolute value (preserves type if int)
math.clamp(v, lo, hi)int/floatClamp v to [lo, hi] (int if all args are int)
math.sqrt(n)floatSquare root
math.pow(base, exp)floatbase raised to exp
math.sin(n)floatSine (radians)
math.cos(n)floatCosine (radians)
math.atan2(y, x)floatTwo-argument arctangent
math.lerp(a, b, t)floatLinear interpolation: a + (b - a) * t
math.rand(min, max)intRandom integer in [min, max] inclusive
math.rand_float()floatRandom float in [0, 1)

Constants

NameValue
math.PI3.14159265…
math.TAU6.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.

FunctionReturnsDescription
time.now()floatSeconds elapsed since engine start
time.ms()intMilliseconds 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

FunctionReturnsDescription
color.rgb(r, g, b)recordColor with alpha 255. Args default to 0 if missing.
color.rgba(r, g, b, a)recordColor with explicit alpha. a defaults to 255.
color.hex(s)recordParse a CSS-style hex string into the same Color record.

color.hex accepts:

  • #RRGGBB or RRGGBB — alpha 255
  • #RRGGBBAA or RRGGBBAA — includes alpha
  • #RGB or RGB — each digit is expanded (e.g. #f0a → red 255, green 0, blue 170)
  • #RGBA or RGBA — 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

NameRGBA
color.White255, 255, 255, 255
color.Black0, 0, 0, 255
color.Red255, 0, 0, 255
color.Green0, 255, 0, 255
color.Blue0, 0, 255, 255
color.Yellow255, 255, 0, 255
color.Cyan0, 255, 255, 255
color.Magenta255, 0, 255, 255
color.Transparent0, 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

FunctionReturnsDescription
fmt.str(v)textConvert any value to text. Text passes through; others are stringified.
fmt.join(items, sep?)textJoin a list into text with sep (default ""). Non-list → empty text.
fmt.format(template, ...args)textReplace {} 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

FunctionReturnsDescription
json.decode(text)valueParse JSON text into an Epitaph value. Returns void on error.
json.encode(value)textCompact JSON string.
json.encode_pretty(value)textPretty-printed JSON string.

Type mapping

EpitaphJSON
int, floatnumber
boolboolean
textstring
voidnull
listarray
map, recordobject
symbolstring (:id format)
bytesarray 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.

FunctionReturnsDescription
string.pad_left(s, width, ch)textLeft-pad s to width with character ch
string.pad_right(s, width, ch)textRight-pad s to width with character ch
string.repeat(s, count)textRepeat 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.

FunctionReturnsDescription
list.contains(items, target)boolactive if target is in items
list.reverse(items)listNew 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):

FunctionReturnsDescription
vec.vec2(x, y)Vec2New vector
vec.vec3(x, y, z)Vec3New vector

Instance / static methods (same naming as other fragments):

CallReturnsDescription
u.add(v) / Vec2.add(u, v)Vec2 / Vec3Component-wise add
u.sub(v)Vec2 / Vec3Component-wise subtract
u.scale(s)Vec2 / Vec3Multiply each component by s
u.dot(v)numDot product
u.len_sq()numSquared Euclidean length
u.cross(v) (Vec3 only)Vec3Cross 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

FunctionReturnsDescription
bytes.new(size)bytesZero-filled buffer of size bytes. Default 0.
bytes.from_list(list)bytesBuffer from a list of ints (each truncated to u8)
bytes.to_list(buf)listList of ints, one per byte
bytes.slice(buf, start?, end?)bytesSub-buffer. Bounds are clamped. Defaults: start=0, end=len.
bytes.from_text(text)bytesUTF-8 encoded bytes
bytes.to_text(buf)textDecode 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.

FunctionReturnsDescription
bytes.read_u8(r)intRead 1 byte (0–255)
bytes.read_i16(r)intRead signed 16-bit integer (big-endian)
bytes.read_i16_le(r)intRead signed 16-bit integer (little-endian)
bytes.read_i32(r)intRead signed 32-bit integer (big-endian)
bytes.read_i32_le(r)intRead signed 32-bit integer (little-endian)
bytes.read_i64(r)intRead signed 64-bit integer (big-endian)
bytes.read_i64_le(r)intRead signed 64-bit integer (little-endian)
bytes.read_f32(r)floatRead 32-bit float (big-endian)
bytes.read_f32_le(r)floatRead 32-bit float (little-endian)
bytes.read_f64(r)floatRead 64-bit float (big-endian)
bytes.read_f64_le(r)floatRead 64-bit float (little-endian)
bytes.read_bytes(r, n)bytesRead n raw bytes
bytes.read_text(r, n)textRead n bytes as UTF-8 text
bytes.remaining(r)intBytes 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.

FunctionArgsDescription
bytes.write_u8(w, val)intWrite 1 byte (truncated to u8)
bytes.write_i16(w, val)intWrite signed 16-bit (big-endian)
bytes.write_i16_le(w, val)intWrite signed 16-bit (little-endian)
bytes.write_i32(w, val)intWrite signed 32-bit (big-endian)
bytes.write_i32_le(w, val)intWrite signed 32-bit (little-endian)
bytes.write_i64(w, val)intWrite signed 64-bit (big-endian)
bytes.write_i64_le(w, val)intWrite signed 64-bit (little-endian)
bytes.write_f32(w, val)floatWrite 32-bit float (big-endian)
bytes.write_f32_le(w, val)floatWrite 32-bit float (little-endian)
bytes.write_f64(w, val)floatWrite 64-bit float (big-endian)
bytes.write_f64_le(w, val)floatWrite 64-bit float (little-endian)
bytes.write_bytes(w, src)bytesAppend raw bytes
bytes.write_text(w, text)textAppend 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.

FunctionArgsReturnsDescription
sys.warp(target)symbol or textvoidRequest 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()voidRequest engine shutdown. The game loop exits after the current tick completes.
sys.is_headless()boolactive 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.

FunctionArgsReturnsDescription
log.info(...)variadicvoidLog with [ep:info] prefix
log.warn(...)variadicvoidLog with [ep:warn] prefix
log.error(...)variadicvoidLog with [ep:error] prefix
log.debug(...)variadicvoidLog 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.

FunctionArgsReturnsDescription
input.poll()symbol or voidPop the next input command from the queue. Returns void if empty.
input.peek()symbol or voidView the next command without consuming it.
input.held(key)textboolWhether key is currently held down.
input.count()intNumber of queued commands.
input.bind(key, command)text, textvoidBind a key name to a command name.
input.unbind(key)textvoidRemove 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.

FunctionArgsReturnsDescription
event.poll()value or voidRemove and return the next event. void if empty.
event.peek()value or voidNext event without removing it.
event.count()intNumber of queued events.
event.clear()voidDiscard all queued events.
event.emit(tag)symbolvoidEnqueue that symbol (including any payload on the literal, e.g. :err("msg")).
event.emit(tag, meta)symbol, anyvoidEnqueue 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().

FunctionArgsReturnsDescription
mouse.x()floatHorizontal position in buffer pixels.
mouse.y()floatVertical position in buffer pixels.
mouse.logical_x()floatX in logical screen units.
mouse.logical_y()floatY in logical screen units.
mouse.inside()boolWhether the pointer maps inside the buffer rectangle.
mouse.down(name)textbool"left", "right", or "middle" held.
mouse.pressed(name)textboolSame names; true only on the frame the button went down.
mouse.scroll_x()floatHorizontal scroll delta this frame (platform units).
mouse.scroll_y()floatVertical 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.

FunctionArgsReturnsDescription
gamepad.count()intNumber of gamepad slots in the current snapshot.
gamepad.connected(i)intboolWhether slot i has a connected controller.
gamepad.axis(i, name)int, textfloatStick/trigger axis: lx, ly, rx, ry, lt, rt (triggers 0..1).
gamepad.down(i, name)int, textboolNamed button held (see table below).
gamepad.pressed(i, name)int, textboolButton 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:

  1. Color record: A record with r, g, b, a fields (from color.rgb, color.rgba, or color.hex).
  2. Three integers: r, g, b as separate arguments with alpha defaulting to 255.

Functions

FunctionArgsReturnsDescription
gfx.fill_screen(color)color record, or r, g, bvoidFill 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, bvoidFill a rectangle.
gfx.draw_text(text, x, y, color?, font?, scale?)see belowvoidDraw 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/floatvoidDraw 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/floatvoidCropped blit; optional 8th img_scale same semantics as draw_image.
gfx.push_clip(x, y, w, h)4 intsvoidPush 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()voidPop 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()intLogical screen width.
gfx.screen_height()intLogical screen height.
gfx.set_font(font)symbolvoidSet the default font. :small for small font, :normal for normal.
gfx.load_font(path)stringboolLoad 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()voidClear the loaded .epf font and use the built-in bitmap font again.
gfx.set_text_outline(color?)color record, r,g,b, or falsevoidEnable 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 nothingvoidControls 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: :small or :normal symbol (default: last set_font or 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:

  1. Backend — an [AudioSink] implementation that decodes and plays sound. ns-audio ships a ready-made rodio backend behind the rodio feature (on by default). Pass it to the engine with engine.set_audio(...). If no backend is set the engine defaults to silent no-op.
  2. Script pluginAudioRuntime registers the audio.* and assets.load_sound host functions. Install it with engine.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.

FunctionArgsReturnsDescription
audio.play_sfx(src, loop?)int handle or string path, optional boolintPlay 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 boolvoidPlay background music. loop defaults to true.
audio.play(src, loop?)int handle or string path, optional boolintConvenience alias — plays a sound effect. Returns a playback handle.
audio.stop_bgm()voidStop background music.
audio.stop(handle)intvoidStop a specific sound by playback handle.
audio.stop_all()voidStop all playing sounds.
audio.set_volume(level)floatvoidSet master volume (0.0 – 1.0).

Loading sounds

When the audio plugin is installed, it adds load_sound to the assets record:

FunctionArgsReturnsDescription
assets.load_sound(path)stringintLoad 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:

  1. File extension on path (e.g. .png, .qoi, .epd, .json, .toml).
  2. Magic sniffimages only: PNG signature and QOI qoif. There is no JSON sniff; use .json or load_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".

FunctionArgsReturnsDescription
assets.load_data(path, format?)text, optional formatvalue or voidReturn 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 formatintReturn 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)intintWidth of a loaded image in pixels. Returns 0 for invalid handles.
assets.image_height(handle)intintHeight 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 the image feature) → 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).

FunctionArgsReturnsDescription
save.write(slot, ...values)text, variadicvoidSave values to a named slot.
save.read(slot)textlistLoad saved values. Returns empty list if slot doesn’t exist.
save.delete(slot)textvoidDelete a saved slot.
save.exists(slot)textboolCheck 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

FunctionArgsReturnsDescription
window.open(w, h, title)int, int, textvoidOpen/resize the window.
window.close()voidClose the window.
window.set_size(w, h)int, intvoidResize the window.
window.set_title(title)textvoidSet the window title.
window.set_fullscreen(on?)boolvoidToggle fullscreen.
window.set_vsync(on?)boolvoidToggle vsync.
window.set_resizable(on?)boolvoidToggle resizable.
window.set_frame_rates(tick_hz, render_hz)int, intvoidSet simulation tick rate and display refresh hint together (one shell update). Same effect as engine.set_tick_rate + engine.set_render_rate.

Query Functions

FunctionReturnsDescription
window.width()intCurrent window width.
window.height()intCurrent window height.
window.is_fullscreen()boolWhether the window is fullscreen.
window.is_focused()boolWhether the window has focus.
window.was_resized()boolWhether the window was resized this frame.

Events

FunctionReturnsDescription
window.poll_event()symbol, text, or boolPop 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::new then allocates no pixel buffer (buffer is empty; tw / th stay 0). Logical screen_w / screen_h in config are unchanged for scripts (and for engine.screen_width() / engine.screen_height() from Epitaph once hosts are registered).
  • Use NoopHeadlessShell (or your own PlatformShell stub) with Engine::run: the main loop still accumulates real time, but it never runs game.draw, never flushes draw lists to the buffer, and never calls present_buffer.
  • For exactly N ticks without a wall-clock outer loop, call Engine::run_headless_ticks after load_entry. It runs the same bootstrap as run (on_init, game.init, on_ready), then N times: input poll (empty on NoopHeadlessShell), at most one coroutine resume, one game.tick, side-effect dispatch, and audio drain — still no draw. It returns Err if headless is not set.
  • Engine::bootstrap_for_run is pub if you need init-only from Rust without entering run.

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_roleRuntimeRole: OfflineSingleplayer (default, unchanged single-player), Client, or Server. Set when constructing EngineConfig before Engine::new; not meant to change at runtime.
  • EngineConfig.headless — composes with the section above; scripts read it via sys.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

FunctionArgsReturnsDescription
engine.set_tick_rate(hz)intvoidSet the simulation tick rate (default 60).
engine.set_render_rate(hz)intvoidSet the render frame rate.
engine.set_scale(n)intvoidSet the pixel scale factor.
engine.set_screen_size(w, h)int, intvoidSet 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

FunctionReturnsDescription
engine.tick_rate()intCurrent tick rate in Hz.
engine.render_rate()intCurrent render rate in Hz.
engine.scale()intCurrent pixel scale.
engine.screen_width()intLogical screen width.
engine.screen_height()intLogical 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).

FunctionArgsReturnsDescription
tilemap.set_grid_size(n)int / floatvoidSet map edge length to n cells (clamped to 1..1024). Reallocates storage to n×n and zeros it.
tilemap.grid_size()intCurrent map edge length in cells.
tilemap.load_grid_from_asset(path, flush?)text, optional bool / symbol / textboolLoad 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, intintBind 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 textvoidSet 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)intvoidSet cell size in pixels.
tilemap.set_view_tiles(n)intvoidSet view radius in tiles.
tilemap.set_terrain_cap(cap)intvoidSet terrain passability threshold.

Camera

FunctionArgsReturnsDescription
tilemap.snap_camera(q, r)int/float, int/floatvoidInstantly move camera to cell.
tilemap.follow_camera(q, r, dt)int/float, int/float, floatvoidSmoothly move camera toward cell.

Queries

FunctionArgsReturnsDescription
tilemap.is_passable(q, r)int, intboolWhether a cell is passable.
tilemap.get_cell(q, r)int, intintTile index at cell.
tilemap.cell_to_screen_x(q)int/floatintScreen X for cell coordinate.
tilemap.cell_to_screen_y(r)int/floatintScreen Y for cell coordinate.

Rendering

FunctionArgsReturnsDescription
tilemap.draw(ox, oy)int, intvoidDraw 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.

FunctionArgsReturnsDescription
ecs.spawn()intCreate a new entity. Returns entity ID, or -1 if no ECS adapter.
ecs.destroy(id)intvoidRemove an entity.
ecs.set(id, comp, value?)int, text, valuevoidSet a named component on an entity. Value defaults to void.
ecs.get(id, comp)int, textvalueGet a component value. Returns void if not found.
ecs.remove(id, comp)int, textvoidRemove a component from an entity.
ecs.query(mask)textlistQuery 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.

FunctionArgsReturnsDescription
net.status(handle)inttext or voidConnection status string. Returns void for unknown handles.
net.poll_event()record or voidPop the next network event. Returns void if no events.
net.close(handle)intvoidClose any connection or cancel a request.

Status Values

ProtocolPossible 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 typeExtra fieldsDescription
"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

FunctionArgsReturnsDescription
net.tcp_connect(host, port)text, intintStart an async TCP connection. Returns a handle immediately.
net.tcp_send(handle, data)int, textbool or textSend UTF-8 data. Returns active on success, or an error message string.
net.tcp_recv(handle)inttext or voidPop next received data chunk. Returns void if nothing queued.
net.tcp_close(handle)intvoidClose the connection.

Server Functions

FunctionArgsReturnsDescription
net.tcp_listen(port)intintStart listening on a port. Returns a server handle.
net.tcp_accept(handle)intint or voidAccept next pending client. Returns client handle or void.
net.tcp_stop(handle)intvoidStop 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

  1. Build a request with net.http_request or a convenience function
  2. Optionally set headers with net.http_set_header
  3. Send the request (automatic for convenience functions)
  4. Poll for the response via net.http_response

Request Functions

FunctionArgsReturnsDescription
net.http_request(method, url)symbol/text, textintCreate 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, textintCreate a request with a body. Does not send yet.
net.http_set_header(handle, name, value)int, text, textvoidSet a header on an unsent request.
net.http_set_headers(handle, headers)int, mapvoidSet multiple headers from a map.
net.http_send(handle)intvoidSend the request (runs in background).

Convenience Functions

These create and immediately send a request in one call:

FunctionArgsReturnsDescription
net.http_get(url)textintGET request
net.http_head(url)textintHEAD request
net.http_delete(url)textintDELETE request
net.http_options(url)textintOPTIONS request
net.http_post(url, body)text, textintPOST request with body
net.http_put(url, body)text, textintPUT request with body
net.http_patch(url, body)text, textintPATCH request with body

Response Functions

FunctionArgsReturnsDescription
net.http_response(handle)intrecord or voidGet 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, texttext or voidGet 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.

FunctionArgsReturnsDescription
net.ws_connect(url)textintOpen a WebSocket connection. Returns handle.
net.ws_send(handle, text)int, textbool or textSend a text message. Returns active on success.
net.ws_send_binary(handle, data)int, textbool or textSend binary data (text bytes). Returns active on success.
net.ws_recv(handle)inttext or voidPop next received message. Returns void if empty.
net.ws_close(handle)intvoidClose 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 void or 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. Use evt.handle to identify which connection an event belongs to.
  • Feature flags (tcp, http, ws) control which protocol implementations are compiled into ns-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:

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

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

run

Run the game binary with cargo run:

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

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

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

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

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


build.toml

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

Full Example

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

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

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

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

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

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

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

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

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

Sections

[scripts]

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

[assets]

Specifies where raw assets come from. Use one of:

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

[[plugin]]

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

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

[[rule]]

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

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

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

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


Plugin System

Built-in Plugins

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

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

ns:font-builder inputs

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

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

Ad-hoc: ns asset

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

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

Plugin Runtimes

ns supports four plugin runtimes:

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

To enable optional runtimes, build with the corresponding feature:

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

Plugin Contract

All plugins implement the same transform contract regardless of runtime:

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

A plugin can:

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

WASM Plugin Example

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

package ns:build@0.1.0;

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

To create a WASM plugin:

1. Create a new crate

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

2. Set up Cargo.toml

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

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

[dependencies]
wit-bindgen = "0.36"

3. Copy the WIT file

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

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

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

struct MyPlugin;

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

export!(MyPlugin);
}

5. Build for WASI

cargo build --target wasm32-wasip2 --release

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

6. Reference in build.toml

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

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

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

Epitaph Plugin Example

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

Lua Plugin Example

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

Rhai Plugin Example

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

Asset Pipeline

When ns build processes assets, it follows this flow:

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

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

PAK Format

Output PAKs use the NSPK format:

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

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


game.toml

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

Full Example

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

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

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

Sections

[paths]

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

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

[keys]

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

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

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

[constants]

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

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

Scripts access these as fields on the config global:

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

Compilation Pipeline

Scripts

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

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

Assets

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

Tips

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