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 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.*)