The app model
The one memory rule that will bite your first app.
An app is a Default struct that holds its state, plus a set of handlers that mutate it. zeph_app!(MyApp) wires the struct to the runtime and generates the exports the device calls. Everything follows from one rule about memory.
The loop
The runtime keeps one instance of your struct alive while the app is open. Every event runs the same cycle: the scratch heap is wiped, your handler mutates &mut self, then render(&self) builds a fresh screen from the current state and pushes it.
One event, one frame
You implement ZephApp. render is required; the handlers are optional and default to doing nothing:
#![no_std]
extern crate alloc;
use alloc::vec;
use zeph_app_sdk::{zeph_app, UiNode, ZephApp};
#[derive(Default)]
struct Counter { count: i32 }
impl ZephApp for Counter {
fn render(&self) -> (&'static str, UiNode) {
("counter.default", UiNode::screen(vec![
UiNode::eyebrow("COUNTER"),
UiNode::big(alloc::format!("{}", self.count)),
]))
}
fn on_intent(&mut self, _intent_id: u32, _phase: u32) {
self.count += 1;
}
}
zeph_app!(Counter);The rule that bites
The scratch heap is reset before every handler. Your struct must hold scalars, never a Vec or a String.
That heap is a small bump arena. render leans on it hard — the whole UiNode tree you build each frame is allocated there, which is exactly what it is for. But it gets reset at the top of the next handler — the offset goes back to zero and the next frame overwrites those bytes. Anything your struct pointed into it now dangles.
So keep app state in plain fields — i32, u8, bool, small fixed arrays. A screen with a page, two toggles, and a slider is four scalar fields:
#[derive(Default)]
struct KitchenSink { page: u8, toggle_a: bool, toggle_b: bool, slider: i32 }A String field is the classic first-app crash
It compiles, it renders once, then the next press wipes the bytes underneath it and the app traps. Build strings inside render from scalar state; never store them across a handler.
The handlers
Each fires from a real device event, mutates &mut self, and the runtime re-renders after.
on_intent(id, phase)
id identifies which node was pressed.on_value(id, value)
value is 0..=100.on_message(&[u8])
on_media(event, position_ms)
There are lifecycle hooks too. on_install runs once on the first open after an install or version bump — seed your state there. on_pause and on_resume bracket the screen going dark while you stay open. on_stop fires just before teardown on close, app-switch, or uninstall.
Persisting state
The instance survives pause and resume, but closing the app tears it down; reopening builds a fresh Default. Scalar fields do not outlive that. To carry state across opens, write it to the app's own data store and read it back:
fn on_stop(&mut self) {
save_clip("state", &self.count.to_le_bytes());
}
fn on_install(&mut self) {
if let Some(b) = load_clip("state") {
self.count = i32::from_le_bytes(b[..4].try_into().unwrap());
}
}Why the arena, and why one instance
The device runs your app in a small interpreter built for a microcontroller, single-threaded, one instance at a time. There is no OS heap to lean on, so the SDK ships a fixed bump allocator — roughly 32 KB — as the guest's global allocator. Freeing individual allocations is a no-op; the whole arena is dropped in one move at the top of each exported entry. That makes per-frame allocation cheap and leak-proof, at the cost of one rule: nothing that lives across a frame may point into it. Your struct is that long-lived thing, so it stays scalar. The render tree is the transient thing, so it allocates freely.