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

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)
    }
}