Epitaph Language Reference
Epitaph is a bytecode-compiled scripting language for the NetSlum engine. It
uses thematic keywords inspired by .hack – phase instead of function,
when instead of if, active/dormant instead of true/false, and so on.
Pipeline
source (.ep) → Lexer → tokens → Parser → AST → Compiler → Module (bytecode) → VM
Modules can be serialized to .epc (Epitaph Pre-Compiled) files and bundled
into .pak archives via the ns CLI. The engine loads bytecode directly at
runtime – no source parsing needed in release builds.
Comments
Single-line comments start with -- (double dash):
-- this is a comment
let x = 42 -- inline comment
There are no block comments.
Sectors (Modules)
Every .ep file must begin with a sector declaration — it is the module
namespace:
sector game
The sector name qualifies all phases, module-level variables, and codex entries
defined in the file. A phase init() inside sector game becomes game.init
at runtime. Omitting sector is a parse error.
Imports
The access keyword brings in external code:
access "battle_utils" -- file import (loads scripts/battle_utils.ep)
access <math> -- stdlib module (host-backed)
access <string> -- stdlib module (pure Epitaph)
access <vec> -- stdlib module (pure Epitaph, 2D/3D vectors)
access hub.volume_key -- legacy dotted path import
Import kinds
| Syntax | Resolves to |
|---|---|
access "name" | File scripts/name.ep (relative to script root) |
access <name> | Standard library module |
access a.b | Legacy path import (first segment = filename) |
Imports are processed recursively before the current file is compiled, so imported phases and globals are available immediately.
Types
Epitaph has 11 value types:
| Type | Literal | Description |
|---|---|---|
int | 42, -7, 0xFF, 0b1010, 0o77 | 64-bit signed integer |
float | 3.14, 0.5, 42f | 64-bit IEEE 754 float |
bool | active, dormant | Boolean (also accepts true/false) |
text | "hello" | UTF-8 string (ref-counted) |
symbol | :done, :attack(target) | Interned tag with optional payload |
list | [1, 2, 3] | Ordered, growable collection |
map | {"hp": 100, "sp": 50} | Key-value dictionary (insertion-ordered) |
range | 0..10 | Half-open integer range [start, end) |
record | Player() | Fragment instance (named struct) |
bytes | (no literal) | Mutable byte buffer (ref-counted) |
void | void | Null / absence of value |
Truthiness
- Falsy:
dormant(false),0,void, emptybytes - Truthy: everything else
Optional type annotations (gradual)
Types are optional. When present, the compiler checks that values match the hint (single file only; imported callees and host functions are treated as unknown).
| Site | Syntax |
|---|---|
| Phase parameter | name or name: Type |
| Phase return | After ): -> Type then { |
let / module let / fixed | let x: Type = expr |
| Fragment field | name = expr or name: Type = expr |
Type expressions: primitives (int, float, bool, text, symbol,
void, bytes, range), a fragment or codex name declared in the
same script (nominal record / codex tag), and list[Inner] for homogeneous
lists.
Literals
Integers
42 -- decimal
-7 -- negative
0xFF -- hexadecimal (0x or 0X prefix)
0b1010 -- binary (0b or 0B prefix)
0o77 -- octal (0o or 0O prefix)
Binary and octal literals allow underscores for readability: 0b1010_0011.
Floats
3.14 -- decimal point
0.5 -- leading zero required
42f -- float suffix (integer parsed as float)
0b1010f -- binary-to-float (10.0)
0o77f -- octal-to-float (63.0)
The f suffix converts any integer literal (decimal, binary, or octal) to a
float. It is not available on hex literals because f is a valid hex digit.
Booleans
active -- true
dormant -- false
true -- alias for active
false -- alias for dormant
Text (Strings)
"Hello, world!"
"line one\nline two"
"tab\there"
"escaped \" quote"
"backslash \\"
Supported escape sequences: \n, \t, \\, \".
Symbols
Symbols are interned tags used for lightweight enumerations and message passing:
:done
:attack
:confirm
Symbols can carry a payload value:
:damage(25)
:move(target)
The payload is accessible via the .data field on the symbol value.
Lists
let items = [1, 2, 3]
let mixed = ["sword", 42, active]
let empty = []
Access elements by index (zero-based):
items[0] -- 1
items[2] -- 3
Maps
let m = {"hp": 100, "sp": 50}
m["hp"] -- 100
m["hp"] = 80 -- update
Keys can be any expression. Values are accessed with bracket notation.
Ranges
0..10 -- Range from 0 (inclusive) to 10 (exclusive)
Ranges are primarily used with traverse:
traverse i in 0..5 {
-- i = 0, 1, 2, 3, 4
}
Variables
Local variables
let x = 42
let name = "Kite"
x = x + 1 -- reassignment
Variables are block-scoped. A let in an inner block shadows outer names.
Module-level globals
Top-level let statements become globals, initialized when the script loads:
sector game
let score = 0
let level = 1
These are accessible as game.score and game.level from within the sector,
and can be read cross-sector (see Cross-Sector Access).
fixed bindings and fixed phase
fixed name = expr declares a module global like let, but it cannot be
reassigned anywhere (a compile error). The initializer is evaluated at
compile time: it may use literals, arithmetic, comparisons, bitwise int
operators (&, |, ^, <<, >>, ~), symbol
literals with const payloads, calls to fixed phase functions that appear
earlier in the file, and identifiers naming fixed bindings also
declared earlier.
fixed phase name(params) { body } defines a const-safe function. The body
may only use let statements and must end with resolve expr (no when,
inspect, host calls, etc. in the body). The same const-expression rules apply
inside those expressions. At runtime, compiled bytecode is still emitted, so
ordinary phase code may call a fixed phase like any other function.
Order: A fixed initializer or fixed phase body may only reference
fixed phase names and other fixed globals that appear above it in the
source file (no mutual recursion).
Call syntax: Inside const contexts, a call must target a prior fixed phase by a single identifier — e.g. sum(1, 2, 3). Dotted callees such as
menu.items() or other.phase() are not evaluated at compile time and produce
a compile error that points at the callee.
Phases (Functions)
Phases are the fundamental unit of code. They take parameters and optionally
return a value with resolve:
phase greet(name) {
resolve "Hello, " + name
}
phase tick(dt) {
let msg = greet("Kite")
}
A phase without an explicit resolve implicitly returns void.
Phases are called by name. Within the same sector, bare names work:
greet("Kite")
Cross-sector calls use qualified names:
utils.format_hp(current, max)
Fragments (Structs)
Fragments define named record types with default field values:
fragment Player {
hp = 100
name = "Kite"
pos = Transform()
}
fragment Transform {
x = 0
y = 0
}
Create instances by calling the fragment name:
let p = Player()
p.hp = p.hp - 20
p.pos.x = 42
Fields are mutable and support nested access within the same sector that
constructed the instance. At runtime, each fragment value remembers the
owner sector (the sector of the script whose constructor ran). Direct assignment to a field (p.x = 1) is
allowed only while executing code whose chunk belongs to that same owner
sector. Other sectors may still read fields and must route writes through
phases defined in the owner sector (for example owner.helper(p, v) or a
fragment method that runs under the owner’s bytecode), mirroring how
cross-sector module let bindings are read-only to foreign assigners.
Embedding (embed)
embed Other; flattens Other’s fields (and nested embeds) into the record
and promotes its fragment methods (phase Other.foo(self) { ... } becomes
callable as outer.foo() on instances of the outer fragment). Field name
collisions between embeds and explicit fields are compile errors.
Fragment methods
Top-level phases named Fragment.method define methods: the first parameter
must be self. Calls: recv.method(args) or Fragment.method(recv, args) when
Fragment is the type name (not a local/global binding). Resolution is limited
to the current script.
Custom construction (ctor)
Fragment() uses field defaults. For Fragment(e1, e2, …) in the same
script as the fragment, define phase Fragment.ctor(e1, e2, …) with no
self, at least one parameter, returning a new instance (typically let r = Fragment(); …; resolve r). That desugars to sector.Fragment.ctor(…).
Cross-module callers should use a module helper (for example vec.vec2(x, y) after access <vec>) instead of bare Vec2(x, y).
Vectors (access <vec>)
See docs/standard-library.md. Epitaph does not
yet support vector literals (such as v2{1, 2}) or operator overloading
on records (u + v); those would require new language/VM work.
Codex (Enums / Constants)
Codex blocks define named constant groups:
codex State {
boot = 0
title = 1
play = 2
}
codex Assets {
bg_title = "images/bg_title.png"
sfx_beep = 13
}
Access variants with dot notation:
let s = State.boot -- 0
let img = Assets.bg_title -- "images/bg_title.png"
Supported value types: int, float, text, bool, symbol. At runtime,
codex entries are flat globals (State.boot, State.title, etc.).
Spells (removed)
The old spell "Name" { ... } form is not part of the language anymore.
Use a fragment for structured metadata (fields with defaults) and a
phase (or fixed phase) for callable logic with a stable name:
fragment FireballData {
element: symbol = :fire
cost: int = 12
power: int = 45
}
phase Fireball() {
let d = FireballData()
resolve d.power * 2
}
Operators
Arithmetic
| Operator | Description | Notes |
|---|---|---|
+ | Add | Also concatenates text and bytes |
- | Subtract | |
* | Multiply | |
/ | Divide | |
% | Modulo | |
- (unary) | Negate |
Int and float values mix freely – if either operand is a float, the result is a float.
Comparison
| Operator | Description |
|---|---|
== | Equal |
!= | Not equal |
< | Less than |
<= | Less than or equal |
> | Greater than |
>= | Greater than or equal |
Bitwise (int only)
| Operator | Description |
|---|---|
& | Bitwise AND |
| | Bitwise OR |
^ | Bitwise XOR |
<< | Left shift (count masked to 0–63) |
>> | Right shift (arithmetic for signed int) |
~ (unary) | Bitwise NOT |
Logic
| Operator | Description |
|---|---|
and | Logical AND (short-circuiting) |
or | Logical OR (short-circuiting) |
not | Logical NOT |
Precedence (lowest to highest)
| Level | Operators |
|---|---|
| 1 | or |
| 2 | and |
| 3 | ==, != |
| 4 | <, <=, >, >= |
| 5 | | (bitwise or) |
| 6 | ^ (bitwise xor) |
| 7 | & (bitwise and) |
| 8 | <<, >> |
| 9 | .. (range) |
| 10 | +, - |
| 11 | *, /, % |
| 12 | Unary -, not, ~ |
| 13 | Postfix: .field, [index], (call) |
Control Flow
when / otherwise (if / else)
when hp <= 0 {
resolve :defeat
} otherwise {
resolve :continue
}
The otherwise branch is optional.
Else-if chaining — use otherwise when to test additional conditions:
when hp <= 0 {
resolve :defeat
} otherwise when hp < 20 {
log.warn("low hp!")
} otherwise {
resolve :continue
}
You can chain as many otherwise when branches as needed. The final
otherwise block is optional and acts as a catch-all.
Expression form (inline conditional):
let label = when level == 0 { "Start" } otherwise { "Level " + level }
inspect (match)
Pattern-match on a value:
inspect cmd {
:attack => { resolve start_attack() }
:skills => { resolve open_skills() }
:flee => { resolve attempt_flee() }
_ => { log.warn("unknown command") }
}
Arms can be single expressions or blocks. Each arm tests equality against the
scrutinee. The _ => arm matches anything and must be last.
Guard conditions — add when after a pattern to apply an extra condition:
inspect damage_type {
:physical when armor > 0 => {
apply_damage(damage - armor)
}
:physical => { apply_damage(damage) }
:magical => { apply_magic_damage(damage) }
_ => { log.warn("unknown type") }
}
Symbol destructuring — bind a symbol’s payload to a variable in the arm:
inspect event {
:error(msg) => { log.error(msg) }
:ok(data) => { process(data) }
:pending => { wait() }
}
:error(msg) matches any :error(...) symbol and binds the payload to msg.
You can also access the payload with .data (e.g. event.data).
Guards and destructuring can be combined:
inspect result {
:ok(val) when val > 0 => { use_value(val) }
:ok => { log.info("zero or negative") }
:error(e) => { log.error(e) }
}
Expression form — assign the result of inspect to a variable:
let name = inspect id {
0 => "Kite"
1 => "BlackRose"
_ => "Unknown"
}
Guards and destructuring work in expression form too:
let label = inspect status {
:ok(msg) => msg
:error(e) => "ERR: " + e
_ => "unknown"
}
sustain (while)
Loop while a condition is truthy:
let i = 0
sustain i < 10 {
i = i + 1
}
Assign-and-test form – binds a value and loops while it is truthy:
sustain cmd = input.poll() {
inspect cmd {
:left => { x = x - 1 }
:right => { x = x + 1 }
}
}
traverse (for-each)
Iterate over a collection or range:
traverse item in items {
process(item)
}
traverse i in 0..10 {
log.info(i)
}
The in keyword separates the binding from the collection.
Alternative as syntax (binding defaults to it if omitted):
traverse items as item {
process(item)
}
traverse items {
process(it) -- implicit binding name
}
resolve (Return)
Returns a value from a phase:
resolve 42
resolve :done
resolve -- returns void
Execution leaves the current phase immediately.
suspend (Coroutine Yield)
Yields a value from a coroutine and pauses execution:
suspend :waiting
suspend 42
suspend -- yields void
The coroutine can be resumed later by the host, which sends a value back. The
suspend expression evaluates to whatever the host sends on resume.
phase countdown() {
let i = 10
sustain i > 0 {
suspend i -- yield current count
i = i - 1
}
resolve :done
}
From the host (Rust) side:
#![allow(unused)]
fn main() {
let result = vm.call_coroutine("game.countdown", &[]);
// result = Suspended(Int(10))
let result = vm.resume(Value::Void);
// result = Suspended(Int(9))
// ... continue until Completed(:done)
}
Bytes
The bytes type represents a mutable buffer of raw bytes. There is no literal
syntax – buffers are created via the bytes standard library module:
access <bytes>
let buf = bytes.new(16) -- 16 zero-filled bytes
buf[0] = 0xFF
let val = buf[0] -- 255 (as int)
let size = buf.len -- 16
Indexing
- Read:
buf[i]returns the byte at indexias anint(0–255). Out-of-bounds returnsvoid. - Write:
buf[i] = valsets bytei. The value is truncated to au8. Out-of-bounds is a runtime error.
Concatenation
The + operator concatenates two byte buffers into a new one:
let combined = buf_a + buf_b
Neither original buffer is mutated.
Fields
.len– the buffer length in bytes
See the Standard Library for the full bytes module API
including ByteReader and ByteWriter.
Networking (ns-network add-on)
When the engine loads the optional ns-network crate, TCP and WebSocket payloads
are bytes values, not UTF-8 text. net.tcp_send and
net.ws_send_binary accept either text (UTF-8) or bytes.
net.tcp_recv, net.ws_recv, and net.poll_event records with
type: data expose a data field as bytes; use bytes.to_text when
you need a string.
Chunked reads: pass a second int argument for a maximum byte count, e.g.
net.tcp_recv(handle, 1024) returns at most 1024 bytes per call (or void if
nothing is queued). Without the second argument, one queued chunk is returned in
full. The same optional limit applies to net.ws_recv.
poll_event vs direct recv: both drain the same per-connection receive
queue; use one style consistently if you want a single consumer.
After receiving bytes, use bytes.reader, bytes.read_u8, etc.,
or bytes.slice, to parse binary protocols.
Cross-Sector Access
Scripts in different sectors can read each other’s module-level globals:
-- In provider.ep
sector provider
let DATA = 42
let LIST = [1, 2, 3]
-- In consumer.ep
sector consumer
phase test() {
let x = provider.DATA -- reads 42
let y = provider.LIST[1] -- reads 2
}
Writing to another sector’s globals is a compile-time error:
provider.DATA = 99 -- ERROR: cannot assign to cross-sector variable
This keeps module boundaries explicit – sectors can expose read-only data but own their mutation.
Fragment instances follow the same idea: another sector cannot assign to your fragment’s fields directly; use a phase or method in the defining sector to mutate.
First-Class Callables
Phases and host functions can be passed around as values. When you reference a phase name without calling it, you get a text value containing the qualified name. Calling a text value dispatches to the named phase or host function:
let f = greet -- text "game.greet" (if in sector game)
f("Kite") -- calls game.greet("Kite")
Execution Model
- Stack-based bytecode VM – max 256 stack slots, 64 call frames
- Single-threaded, synchronous – no async, no threads within the VM
- No garbage collector – values are stack-allocated or ref-counted (
Rc) - Symbols are interned – O(1) comparison via integer IDs
- Module init – top-level
letand deferred codex values run once when a script is loaded (via an internal__module_initchunk) - Deterministic – same inputs produce the same outputs (except
math.randandtime.*)