Every RocRay app has the same shape: three functions that the host calls at the right moments. This chapter explains what each one receives, what it may do, and what it returns. Every later guide builds on it.
The three functions are separate because each has one job. init! sets
things up once. update! is the only place where the model changes, so you
always know where to look for a change. render! only draws the model, so
what you see on screen always matches the app’s state. Anything slow runs
outside update! and render!, so the window never freezes while the app
waits. Why it is shaped this way explains each of these rules.
It assumes you have run the smallest app in [getting-started].
Here is a whole app. It draws a red dot under the pointer and counts your clicks. The rest of this chapter takes it apart.
app [Model, program] { rr: platform "BUNDLE-URL", roc: "COMPILER" }
import rr.App
import rr.Color
import rr.Draw
import rr.Text
Model : {
title : Text.Prepared,
pointer : { x : F32, y : F32 },
clicks : U64,
}
Msg : []
program = { init!, update!, render! }
init! : App.Init(Model, [ResourceLimit])
init! = App.init(
App.default.with_title("Clicker"),
|_io| {
font = Draw.default_font!()
title = Text.from("Click anywhere", font).size(32).prepare!()?
Ok({ title: title, pointer: { x: 400, y: 300 }, clicks: 0 })
},
)
update! : Model, App.Input(Msg), App.Io => Try(Model, [Exit(I64)])
update! = |model, input, _io| {
mouse = input.devices.mouse
clicks = if mouse.button_pressed(Left) model.clicks + 1 else model.clicks
Ok({ ..model, pointer: mouse.position(), clicks: clicks })
}
render! : Model, Draw.Frame => Try({}, [Exit(I64)])
render! = |model, frame| {
frame.clear!(Color.black)
model.title.draw!(frame, { pos: { x: 400, y: 40 }, color: Color.white, align: (Top, Center) })
frame.text_at!({ pos: { x: 20, y: 560 }, text: "Clicks: ${model.clicks.to_str()}", size: 20, color: Color.white })
frame.circle!({ center: model.pointer, radius: 18, style: Draw.filled(Color.red) })
Ok({})
}Replace BUNDLE-URL and COMPILER as [smallest-app] describes. Press
Escape or close the window to quit.
The app’s header, app [Model, program], gives two things to the platform:
the type of its state, Model, and program, a
record of its three callbacks. Msg is the type of
the messages its tasks send back; this app starts no tasks, so Msg is
the empty tag union [].
A name ending in ! is an effectful function: calling it can do something
beyond computing a value, such as drawing or opening a window. Its type uses
=> where a pure function’s uses ->. See
Functions in the Roc language reference.
The host calls the three callbacks in a loop:
flowchart LR
Init["init!<br/>builds the first model"] --> Update
Update["update!<br/>uses one input to make the next model"] --> Render
Render["render!<br/>draws that model"] --> Update
Update -- "Err(Exit(code))" --> Stop([app exits])
init! runs once. After that, each time around the loop is one host cycle:
the host gathers everything that happened since the previous cycle into
one input, calls update! exactly once with it, and then normally calls
render! with the model update! returned. Each call to render! produces
one frame: one image in the window. A headless or minimized run may skip
drawing, but it never calls update! twice with the same input.
The model is your app’s state: everything it needs to remember from one
cycle to the next. You choose its type and name it Model. The clicker keeps
a prepared title, the pointer position, and a count:
Model : {
title : Text.Prepared,
pointer : { x : F32, y : F32 },
clicks : U64,
}Only update! produces a new model. The host keeps the model between calls,
but never reads or changes it. Keep long-lived resources you create in
init!, such as textures, fonts, sounds, prepared text, and file handles, in
the model rather than creating them again every cycle.
init! pairs a startup configuration with a function that builds the first
model:
init! : App.Init(Model, [ResourceLimit])
init! = App.init(
App.default.with_title("Clicker"),
|_io| {
font = Draw.default_font!()
title = Text.from("Click anywhere", font).size(32).prepare!()?
Ok({ title: title, pointer: { x: 400, y: 300 }, clicks: 0 })
},
)The function returns a Try: Ok(model) to start the app, or Err(...) to
stop it. The ? after prepare!() returns early with the error if preparing
the text fails; see Operators.
The second type argument of App.Init lists every error the function may
return. Here it is ResourceLimit, the one error prepare! can produce. When
you call something else with ?, add its errors to this list; the compiler
names any that are missing. Returning Err(Exit(code)) quits before the first
frame with that exit code. Any other error stops the app with a message
naming init! and the error.
App.default is an 800 by 600 window titled "RocRay", capped at 240 frames per
second, that quits when Escape is pressed. Change it with its with_*
methods. A method is a function called with a dot on a value, like
App.default.with_title("Clicker"); see
Static dispatch.
| Method | Sets |
|---|---|
|
The window title |
|
The initial window size, in logical units (see [input-window]) |
|
A minimum size for a resizable window |
|
Window behaviour |
|
Whether the window is shown; a hidden window still renders, which is useful for recording to a file |
|
How often cycles run: |
|
The key that ends the app at once: |
|
What closing the window does: |
|
The initial cursor mode |
|
A font file loaded once at startup for the whole app. Reading it needs a file declaration covering the path, such as |
|
Where captures are written; see [capture] |
|
A recording to describe at startup; see [capture] |
|
The app’s name for its private storage; see [files-and-assets] |
|
What the app can access beyond its own resources; see [permissions] |
A configuration that depends on the command line uses App.init_for_args
instead of App.init. Its first argument is a pure function from the complete
argument list to a Config, chosen before the window exists:
init! = App.init_for_args(
|args| if List.contains(args, "--record") { App.default.with_visible(Bool.False) } else { App.default },
|_io| Ok(initial_model),
)Here initial_model stands for whatever first model your app builds.
The function init! runs receives App.Io, the app’s access to host services
(files, the network, printing, and so on). init! is the one place an app may
wait for the outside world without a task: loading a texture or reading a
file there simply delays startup until the result is ready, before the first
frame is drawn.
A few effects are legal only in init!, because they return information that
is fixed when the app starts:
-
io.args!()returns the command line, with the host’s own--host-*switches removed. Keep what you need from it in the model. -
io.entropy!()gets a random number from the operating system to seed a random generator (see [input-randomness]). -
io.default_font!()returns the font configured withwith_default_font. -
io.env().read!(name)reads a declared environment variable.
update! : Model, App.Input(Msg), App.Io => Try(Model, [Exit(I64)])
update! = |model, input, _io| {
mouse = input.devices.mouse
clicks = if mouse.button_pressed(Left) model.clicks + 1 else model.clicks
Ok({ ..model, pointer: mouse.position(), clicks: clicks })
}update! receives the current model, one App.Input, and the same App.Io
that init! had. It returns Ok with the next model, or Err(Exit(code)) to
stop the app with that exit code. { ..model, pointer: ... } is a copy of
model with some fields replaced; see Records.
App.Input contains everything the host collected for this cycle:
| Field | Contains |
|---|---|
|
The keyboard, mouse, text, and gamepad snapshot; see [input] |
|
The window’s size, focus, whether it is minimized, and whether the user asked to close it |
|
This cycle’s timing, including |
|
The results of tasks that finished since the last cycle; see [tasks] |
|
The status of any recording in progress |
|
Files dropped on the window this cycle |
update! may call host effects directly: play a sound, resize the window,
print a line, start a recording. Those happen immediately, in the order they
are written. What update! must not do is wait. An effect that can wait,
such as reading a file, sending a request, or sleeping, is refused in
update!. Run it in a task instead, and its result arrives in a later
input. [tasks] shows how.
render! : Model, Draw.Frame => Try({}, [Exit(I64)])
render! = |model, frame| {
frame.clear!(Color.black)
model.title.draw!(frame, { pos: { x: 400, y: 40 }, color: Color.white, align: (Top, Center) })
frame.text_at!({ pos: { x: 20, y: 560 }, text: "Clicks: ${model.clicks.to_str()}", size: 20, color: Color.white })
frame.circle!({ center: model.pointer, radius: 18, style: Draw.filled(Color.red) })
Ok({})
}render! receives the model update! just returned and a Draw.Frame.
Everything it draws goes through that frame. It returns {}, not a model: it
cannot change the app’s state. [drawing] covers what a frame can draw.
Because render! gets no input, anything that moves on screen must be read
from the model. An animation keeps its clock in the model, advanced in
update! from input.time.elapsed_seconds.
One rule covers almost every case:
-
Drawing goes in
render!. -
Anything that waits (files, the network, sleeping) goes in a task, or in
init!before the app starts. -
Everything else (reading input, changing the model, playing a sound, starting a task) goes in
update!.
If you break the rule, the app stops at once with a message that names the
effect you called, the callback you called it from, and where it belongs. For
example, reading a file from update! stops the app and tells you to move the
read into a task. Details: which effects are legal where lists the rule for every kind of effect.
An app ends when update! returns Err(Exit(code)). The user can also end
it:
-
Pressing the exit key ends the app at once, before
update!sees the press.App.defaultuses Escape; change it withwith_exit_key(ExitKey(key)), or turn it off withwith_exit_key(NoExitKey)when the app needs Escape for itself, such as to open a pause menu. -
Clicking the window’s close button closes the window. By default the app ends at once, and
update!never sees it happen.
Ending at once suits most games. But tasks that are still running when the
app ends are cancelled, so an app with unsaved work needs a chance to finish
it first. Such an app, for example one that saves the game, can ask to be
notified instead, with with_close_request(Deliver). The window then
stays open, and the next input has input.window.close_requested set to
Bool.True for one cycle. A request is never lost. The app decides when to
end by returning Err(Exit(code)):
Msg : [Saved(Try({}, Files.WriteError))]
update! = |model, input, _io| {
saved = List.any(input.messages, |msg| match msg { Saved(_) => Bool.True })
if saved {
# The save finished, successfully or not: now end the app.
Err(Exit(0))
} else if input.window.close_requested and !model.saving {
# Write the save file on a task, and keep running until it finishes.
saves = model.saves
text = encode_save(model)
Task.spawn!(input, || Saved(saves.write_text!("save.txt", text)))
Ok({ ..model, saving: Bool.True })
} else {
Ok(model)
}
}Here model.saves is a writable directory handle opened in init! (see
[files-and-assets]), and encode_save is the app’s own pure function that
turns the model into text. [tasks] explains Task.spawn!. An app that must
not lose a save can show an error and keep running on Saved(Err(_))
instead of exiting.
The exit key is not a close request. It ends the app directly, with or
without Deliver, and so does a press of it scripted in a test (see
[testing]). With App.default, Escape still quits without saving. An app
that must save on every way out also sets with_exit_key(NoExitKey) and
handles the key it wants in update!.
When the app exits, tasks still running are cancelled. That is why the save waits for its message before exiting, rather than exiting straight away.
Msg is the type of the values your tasks return. An app that starts no tasks
writes Msg : []. One that loads a level might write:
Msg : [LevelLoaded(Try(Str, Files.ReadTextError))]Finished tasks' messages arrive in input.messages. update! applies each
one to the model in turn, usually with List.fold and a pure helper:
Ok(List.fold(input.messages, model, apply_message))[tasks] explains where messages come from and shows apply_message in full.
update! is effectful, so roc test cannot call it. Keep the decisions it
makes in pure functions that take the model and input: which message changes
what, whether to quit, and which tasks to start. Then let update! carry out
what they decide. Those functions are ordinary Roc and can be tested with expect.
[testing] shows how to build inputs and resources for such tests.
If you have written a game in C, you know the classic loop:
while (!WindowShouldClose()) {
PollInput();
UpdateGame(GetFrameTime());
BeginDrawing();
DrawGame();
EndDrawing();
}RocRay’s loop is the same loop: update! is UpdateGame, and render! is
DrawGame. What RocRay adds are a few rules about what each part may do. Each
rule has a benefit:
-
Nothing waits in
update!orrender!. In the C loop, one carelessfopenor HTTP request in the middle of a level makes the game freeze for as long as the disk or network takes. In RocRay that call is refused, so a cycle can only be as slow as your own code and the drawing it asks for. Waiting happens on a task, and the loop keeps going. -
Everything that changes arrives in the input. Keys, time, finished tasks, and window events all arrive in
update!as data. A test can build an input by hand, a headless run can script one, and a recording can reproduce one. The same model and the same inputs lead to the same decisions. -
render!only draws. A frame shows exactly the modelupdate!produced, so a screenshot or a recording shows what the game actually decided. That is also whyrender!cannot read the clock: a frame that read the time would differ from one recording to the next. -
Tasks run one at a time, on the same thread as your callbacks. Nothing runs in parallel with your code, so there are no locks, no data races, and no half-updated model.
-
Decisions are made in pure functions. Code that does not call the host can be tested with
expect, without a window, a GPU, or a sound card.
Roc’s type system cannot express "this function may be called from
update! but not from `render!`", so these rules are checked when the app
runs: the first time a callback breaks one, the app stops with a message
naming the effect, the callback it was called from, and the fix. Run your app
early and often, and read the message when it happens.
If you need something before the game can start, such as a file that must be
read first, do it in init!, which is allowed to wait.
This section is a reference; the one-line rule above covers most apps.
The host checks every effect against the current callback so that the rules
in Why it is shaped this way always hold: nothing waits in update! or render!,
and render! only draws.
The host always knows which callback it is inside: init!, update!,
render!, or a task. It calls this the phase. Every effect states the
phases it is legal in, in its API documentation, using one of a few rules:
| Kind of effect | Legal in |
|---|---|
Changes host state (plays a sound, resizes the window, uploads a texture, starts a recording, reads the clock with |
|
Draws |
|
Waits on the outside world (files, network, databases, programs, sleeping, loading a resource from disk) |
|
Takes a screenshot of the next frame |
Tasks only, since it waits for a frame that |
Starts a task |
|
Ends the app with |
|
Reads a startup fact ( |
|
Reads a value the host already has, in constant time: |
Every callback, |
Diagnostic annotations for [observatory] |
Every callback and tasks |
render! is deliberately narrow. It must draw exactly the model update!
produced, so that a frame, a screenshot, or a recording is an exact picture
of what the app decided. An effect that changes host state, or reads
something that differs from run to run such as the clock or a file, would
break that. So it is refused in render! even when it does not wait. The few queries in the
table above are allowed because they cannot change what happens next, and
reading them allocates nothing.
io.exit!(code) is for places that cannot return Err(Exit(code)), such as
a task. The app ends after the current cycle finishes.
-
[architecture] states these rules precisely and explains why RocRay chose them.
-
The
Apppage of the API reference documents every configuration method andApp.Inputfield.