Skip to content

Runtime & Memory

Writ is a bytecode VM — scripts are compiled to bytecode at load time and executed on a lightweight VM embedded in the Rust host.

Source → Lexer → Parser → AST → Type Checker → Bytecode → VM
  • Scripts loaded and executed at runtime — no ahead-of-time compilation required
  • Static typing means all type checking happens before bytecode is emitted — no runtime type checks
  • Near-zero marshalling cost — script types map directly to Rust types in memory
  • VM starts with no external access — host explicitly registers what scripts can use

Three-tier model — no garbage collector, no GC pauses.

  • Temporaries and local variables
  • Automatic, zero overhead
  • Freed when function returns
  • Heap-allocated objects — collections, script-created instances
  • Freed when no more references exist
  • Predictable, no pause spikes
  • Circular references handled via weak references

The VM guarantees that no script operation can cause a borrow panic internally. When the same object appears as both a method receiver and an argument (e.g., q.dot(q)) or when two arguments to a native function are the same object (e.g., swap(player, player)), the VM automatically detects the aliasing via pointer comparison and clones the conflicting value before dispatching. Detection is zero-cost in the common (non-aliasing) case. All internal borrow sites use fallible borrows so any missed case produces a RuntimeError instead of a process crash. Neither script authors nor host developers need to handle this — it is fully automatic.

  • Entity-bound script objects owned by Rust host
  • Rust’s ownership system manages lifetime
  • When entity is destroyed, script goes with it
  • Zero VM overhead for host-owned objects

All primitive types map directly to Rust equivalents with identical memory layout — no conversion cost at the boundary.

Script typeRust type
inti32 / i64
floatf32 / f64
boolbool
stringString
Array<T>Vec<T>
Dictionary<K, V>HashMap<K, V>
Optional<T>Option<T>
Result<T>Result<T, String>

User-defined types compile to first-class Rust types, not VM-managed wrappers.

Single inheritance via extends compiles to composition with Deref in Rust. The compiler generates this automatically — neither the scripter nor the host developer sees the implementation.

// Generated from: class Player extends Entity { pub health: float }
pub struct Player {
base: Entity,
pub health: f32,
}
impl std::ops::Deref for Player {
type Target = Entity;
fn deref(&self) -> &Entity { &self.base }
}
impl std::ops::DerefMut for Player {
fn deref_mut(&mut self) -> &mut Entity { &mut self.base }
}

Script traits compile to Rust traits. Default implementations are preserved.


If scripts use coroutines (yield, start), register a tick source so the VM knows how to measure time. Coroutines then advance automatically whenever you call into the VM.

Register once during setup:

// Game engine — use your engine's delta time
vm.set_tick_source(|| engine.delta_time());

After this, every call() or load() auto-ticks coroutines before executing. No per-frame tick call needed.

The callback controls what “time” means to coroutines:

  • Return 0.0 to freeze coroutines (pause)
  • Return delta * 0.5 for slow motion
  • Return a fixed value for deterministic playback

For non-game applications, use wall-clock time:

vm.use_wall_clock();

For direct control over timing, skip set_tick_source and call tick() explicitly each frame:

vm.tick(delta_seconds).unwrap();

When a game object is destroyed, cancel its coroutines to avoid dangling execution:

fn on_destroy(vm: &mut Writ, entity_id: u64) {
vm.cancel_coroutines_for_owner(entity_id);
}

Cancellation propagates to child coroutines automatically. See Coroutines for the script-side API.