App

The shape of a RocRay program: its three callbacks, its startup configuration, and the input each cycle folds in.

An app provides three callbacks:

program = { init!, update!, render! }

init! runs once, after the window, renderer, and audio device are ready. It reads startup configuration, loads the resources the app will hold, and returns the first model. Use App.init to pair a Config with it.

update! runs once per host cycle. It receives the model and one App.Input, calls host effects directly, starts tasks, and returns the next model -- or Err(Exit(code)) to stop the app.

render! receives that model and a Draw.Frame, and draws. It cannot change the model or reach host work of any other kind.

Where an effect may be called: the host knows which callback it is inside, and every effect documents the phases it is legal in. Three rules cover nearly all of them. An effect that changes host state -- the cursor, the window, audio, a recording, a loaded resource -- is legal in init!, update!, and tasks, and refused in render!. An effect that draws is legal in render! only, inside the frame scope the host opens around it. An effect that waits -- Files.read_text!, Http.send!, Task.sleep! -- is legal in init!, where it blocks startup, and in tasks, where it parks the task; it is refused in update! and render!.

Those rules are the summary; each effect's own page is the authority, and one waiting effect has a narrower set than the rule. Capture.screenshot! is legal only in a task: what it waits for is the end of a frame, and init! returns before the frame loop has drawn one, so there is nothing for it to wait on there.

Every loader that reads a file waits, so it belongs in init! or in a task: Assets.Store.open!, Assets.load_texture!, Audio.load_sound!, Audio.load_music!, Draw.load_store_font!, Draw.Shader.from_store! and Tilemap.load_tmx!. The constructors that take bytes the app already holds -- Assets.texture_from_bytes!, Draw.font_from_bytes!, Draw.Shader.from_source!, Audio.gen_sound! -- do not wait and are legal in update!. To load after startup, read the file on a task and build the resource in update! when the message arrives, or call the loader inside the task.

Calling an effect from a phase it does not permit is a programmer error, not a runtime outcome: it stops the app at once with a message naming the effect, the phase it was called from, and where it belongs.

Saturation is a runtime outcome and is reported three different ways, because three different things are full. ResourceLimit is one of the host's fixed resource tables -- textures, sounds, fonts, shaders, prepared text -- and is answered by releasing something the app no longer holds. Busy is a delivery slot rather than a resource: Files has 32 for the byte lists it hands over, and Cmd bounds how many children run at once, so the same call later can succeed with nothing released. Sqlite.TooManyConnections is the eight-connection cap on open databases. None of the three is a retry loop's cue on its own; each says which of the three kinds of room ran out.

How messages arrive: work that waits belongs on a task. Task.spawn!(input, || ...), from update! or from another task, hands the host an effectful closure to run on its own stack. When the closure returns, its value is delivered as a message on input.messages in a later cycle, in the order the tasks finished. A task cannot read or write the model, so its message is the only thing it can say. See Task.

Testing: update! is effectful and an expect cannot call it, so the decisions live in pure functions and those are what a test exercises. Three naming conventions supply the values such a test needs. App.Input.for_tests({}) is the composite input, neutral in every field and customized one field at a time with the with_* receivers; for_tests is what any composite input value is called. Devices.none and Devices.empty are the device snapshots, and none/empty is what a neutral device sample is called -- build a test's input from none, which is writable, and seed a model with empty. Every host resource an app can hold has a resource-free stub -- Draw.Font.stub, Audio.Sound.stub, Assets.Store.stub, Text.Prepared.stub -- so a Model full of assets can be written down in a pure test. A stub reaches the host and is treated as a released resource, so it is never a way to test loading or lifetime.

exit! : Startup, I32 => {  }

Exit the application with the given exit code.

The exit happens after startup completes, so init! finishes and the host shuts down in the ordinary way. Legal only in init!.

args! : Startup => List(Str)

Return the complete process argument list supplied by the launcher.

The first element is argv[0], followed by application-owned arguments in order. The host removes its reserved --host-* switches before this list reaches the app. The value is stable for the process lifetime.

Legal only in init!. App.init_for_args is the other way to read argv, before the window exists.

read_env! : Startup, Str => Try(Str, [NotFound, ..])

Read an environment variable by key.

Answers Err(NotFound) when the variable is not set. Legal only in init!.

read_file! : Startup, Str => Try(Str, [NotFound, ReadFailed, ..])

Read a UTF-8 text file from disk, blocking until it is read.

Call as App.read_file!(startup, path). Legal only in init!. Use Files.read_text! inside a task to read a file while the app runs, and for the fuller error report.

entropy! : Startup => U64

Draw one number from the operating system's entropy source.

This is the only thing in the platform that makes a run differ from the last one by itself, and it is deliberately the app's decision:

seed = Random.seed(U64.to_u32_wrap(App.entropy!(startup)))

Keep the returned Random.State in the model and draw with pure Random.Generator values during update!, so the run is reproducible from its seed. A run that must reproduce writes a constant seed instead and never calls this; a run that should vary calls it once. Nothing else about the platform is affected either way, because the generator's state is the model's rather than the host's.

The entropy is real in every mode, including headless: determinism comes from an app choosing a fixed seed, not from the host quietly handing out the same "random" number on every run.

Legal only in init!.

random_i32! : Startup, I32, I32 => I32

Get a varying startup number in the inclusive range [min, max].

Legal only in init!. entropy! is the one to seed a generator from: it draws on the operating system rather than on the backend's own generator, and it says what it is for. This remains for a one-off value in a range, such as a jittered start position that nothing else depends on.

suggest_window_size! : Startup, { width : I32, height : I32 } => Try({  }, [InvalidSize, NotSupported, ..])

Suggest positive initial window dimensions to the window manager.

Answers Err(NotSupported) on a target whose windows cannot be resized. Call as App.suggest_window_size!(startup, size). Legal only in init!. A running app resizes itself with Window.suggest_size!, which reaches the same host call, and only this spelling can report a refusal.

suggest_window_min_size! : Startup, { width : I32, height : I32 } => {  }

Suggest the smallest window size the user can drag the window down to.

Each negative dimension is clamped to 0, which leaves that axis unconstrained. The minimum only applies to a resizable window, so pair it with App.default.with_resizable(Bool.True). Call as App.suggest_window_min_size!(startup, size). Legal only in init!.

set_target_fps! : Startup, I32 => {  }

Set raylib's CPU-side frame-rate cap.

Values at or below zero render uncapped. This neither selects a software renderer nor controls VSync. Call as App.set_target_fps!(startup, fps). Legal only in init!. A running app changes the cap with Window.set_target_fps!.

set_exit_key! : Startup, ExitKey => {  }

Set which key closes the window, or NoExitKey to stop any key from closing it.

raylib defaults to ExitKey(KeyEscape). The window close button is unaffected either way, so an app that disables the exit key should still handle shutdown itself by returning Err(Exit(code)). Call as App.set_exit_key!(startup, NoExitKey). Legal only in init!.

get_clipboard_text! : Startup => Try(Str, [Unavailable, ..])

Read UTF-8 text from the system clipboard.

Answers Err(Unavailable) when the clipboard is empty, holds non-text content, or the windowing backend refuses the request -- the underlying platform does not distinguish these cases. Call as App.get_clipboard_text!(startup). Legal only in init!. A running app reads the clipboard with Window.read_clipboard!, which names the refusals separately.

set_clipboard_text! : Startup, Str => {  }

Replace the system clipboard contents with UTF-8 text.

Call as App.set_clipboard_text!(startup, text). Legal only in init!. A running app writes it with Window.set_clipboard_text!.

set_cursor_mode! : Startup, CursorMode => {  }

Apply cursor visibility and capture atomically through one tagged operation. Legal only in init!. Mouse.set_cursor_mode! is the same change from update! or a task.

set_cursor! : Startup, Cursor => {  }

Set the native operating-system cursor shape. Legal only in init!. Mouse.set_cursor! is the same change from update! or a task.

default : Config

Default 800x600 window configuration capped at 240 FPS.

init : Config, InitCallback(model, errors) -> Init(model, errors)

Build initialization from a static startup configuration.

init_for_args : ConfigForArgs, InitCallback(model, errors) -> Init(model, errors)

Build initialization from an argv-aware startup configuration.

default_test_size : { width : I32, height : I32 }

The window size Input.for_tests reports. Ordinary rather than special: a test that depends on the size should say so with with_window.

FramePacing : AppFramePacing

Mutually exclusive frame pacing strategy: VSync, Capped(fps), or Uncapped. Config normalization maps a non-positive Capped value to Uncapped before a Config can be created.

The signature names the module-private nominal this is an alias of; App.FramePacing is the name to write.

ExitKey : ExitKey

Which key, if any, closes the window: ExitKey(key) or NoExitKey, which disables the behaviour.

This is Keys.ExitKey, re-exported. The signature renders as ExitKey : ExitKey because the alias and the nominal share a name; they are one type, and a value passes between the two spellings freely.

Config

App.Config :: # (opaque)

Validated startup configuration. Its fields cannot be updated directly; use its receiver updates so startup invariants are preserved.

with_title : Config, Str -> Config

Return a config with a different window title.

with_size : Config, { width : I32, height : I32 } -> Config

Return a config with different initial logical dimensions. Each non-positive dimension independently falls back to the 800x600 default.

with_min_size : Config, { width : I32, height : I32 } -> Config

Return a config with a minimum window size the user cannot shrink past. Each negative dimension is clamped to 0, which means "no limit" in that axis. A minimum only takes effect on a resizable window, so pair this with with_resizable(Bool.True).

with_frame_pacing : Config, FramePacing -> Config

Return a config with a validated frame-pacing strategy.

with_exit_key : Config, ExitKey -> Config

Return a config with a different exit key. NoExitKey stops any key from closing the window; the window close button still works.

with_cursor_mode : Config, CursorMode -> Config

Return a config with a different initial cursor mode.

with_resizable : Config, Bool -> Config

Return a config that enables or disables native window resizing.

with_fullscreen : Config, Bool -> Config

Return a config that starts in or out of fullscreen mode.

with_visible : Config, Bool -> Config

Return a config whose window is shown or hidden at startup.

A hidden window still renders on the GPU, so Capture works exactly as it does with a visible one -- useful for rendering a chart or a short clip to a file without a window appearing. This is not the same as the host's --host-headless flag, which draws nothing at all, and it still needs a display server (wrap it in xvfb-run on a machine without one).

with_output_dir : Config, Str -> Config

Return a config whose captures are written under a different directory, created on first use.

Every Capture path resolves beneath this directory, and one that would escape it -- an absolute path, or one containing .. -- is refused rather than rewritten. The directory itself is the app author's choice and is used as given, so it may be absolute; what it bounds is where the paths an app computes at runtime can reach. An empty value means the working directory.

with_recording : Config, Recording -> Config

Return a config that starts recording before the first frame.

This is how an app captures itself with no runtime code at all, so a visualization can be rendered straight to a file. The recording finalizes when it reaches its frame cap, when Capture.stop! is called, or when the app exits.

without_recording : Config -> Config

Return a config with startup recording disabled.

title : Config -> Str

Inspect the window title.

size : Config -> { width : I32, height : I32 }

Inspect the initial logical window dimensions.

min_size : Config -> { width : I32, height : I32 }

Inspect the minimum window size. A 0 in either axis means the window is unconstrained in that direction.

frame_pacing : Config -> FramePacing

Inspect the selected frame-pacing strategy.

cursor_mode : Config -> CursorMode

Inspect the selected initial cursor mode.

resizable : Config -> Bool

Inspect whether the window is natively resizable.

fullscreen : Config -> Bool

Inspect whether the window starts fullscreen.

exit_key : Config -> ExitKey

Inspect the selected exit key.

visible : Config -> Bool

Inspect whether the window is shown at startup.

output_dir : Config -> Str

Inspect the directory captures are written under.

recording : Config -> [NoRecording, Record(Recording)]

Inspect whether the app records itself from startup.

Startup : Startup

Opaque, zero-sized authority the host supplies only while it runs init!.

The signature renders as Startup : Startup because this is an alias of an identically named private nominal. There is nothing to construct: the host hands one to the init! callback and that is the only one there is.

Every effect that takes a Startup is legal only in init!. Startup provides one-shot system effects but no input, window, or timing observations: seed models that require devices with Devices.empty, and the first App.Input supplies the first sampled values. After initialization, change host state by calling effects from update!, and ask for work that waits with Task.spawn!.

InitCallback : Startup => Try(model, [Exit(I64), ..errors])

Effectful startup callback run after the host has initialized raylib and audio. Return Ok(model) to start the app, Err(Exit(code)) to quit before the first frame, or let other initialization errors propagate.

ConfigForArgs : List(Str) -> Config

Pure startup configuration chosen from the complete process argv before the host creates its native window. This is where an app can opt into a hidden recording mode based on its own command-line flags.

Init : {
    config : ConfigForArgs,
    run! : InitCallback(model, errors),
}

Startup configuration paired with an effectful model initializer.

Dropped : Dropped

One file dropped onto the window, and where the pointer was when it landed.

path is absolute, as the window system reported it. Nothing in the platform sandboxes it, so it is read the way any other path is: hand it to Files.read_bytes! inside a task, and the read parks that task while the frame loop keeps drawing.

Task.spawn!(input, || Opened(Files.read_bytes!(drop.path)))

Declared in the roc-ray-types package's App and re-exported here.

Input : Input(msg)

Everything the host observed for one cycle, handed to update!.

messages contains every task message delivered for this cycle, in the order the tasks finished. capture contains the recording status sampled for this cycle, and dropped the files dropped onto the window since the previous input.

An Input(msg) is also the witness that pins a task's message type: Task.spawn!(input, || ...) takes one for that reason, and so should any public function of a package that starts work on the app's behalf. See Task.spawn!.

Declared in the roc-ray-types package's App and re-exported here, which is also where its receivers are documented. A package can therefore accept a whole Input(msg) without depending on this platform.