Skip to content

Latest commit

 

History

History
470 lines (384 loc) · 19.8 KB

app-model.adoc

File metadata and controls

470 lines (384 loc) · 19.8 KB

The app model: init!, update!, and render!

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].

A complete app

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 loop

The host calls the three callbacks in a loop:

The order in which the host calls an app
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

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!: configuration and the first model

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.

The startup configuration

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

with_title(str)

The window title

with_size({ width, height })

The initial window size, in logical units (see [input-window])

with_min_size({ width, height })

A minimum size for a resizable window

with_resizable(bool), with_fullscreen(bool)

Window behaviour

with_visible(bool)

Whether the window is shown; a hidden window still renders, which is useful for recording to a file

with_frame_pacing(pacing)

How often cycles run: VSync, Capped(fps), or Uncapped; see [input-frame-pacing]

with_exit_key(key)

The key that ends the app at once: ExitKey(key), or NoExitKey for none (see Closing the window)

with_close_request(mode)

What closing the window does: Exit ends the app (the default); Deliver reports the request to update! instead (see Closing the window)

with_cursor_mode(mode)

The initial cursor mode

with_default_font({ path, size })

A font file loaded once at startup for the whole app. Reading it needs a file declaration covering the path, such as Directory("assets", ReadOnly); see [permissions]

with_output_dir(path)

Where captures are written; see [capture]

with_recording(recording)

A recording to describe at startup; see [capture]

with_app_id(id)

The app’s name for its private storage; see [files-and-assets]

with_permission(p), with_permissions(list)

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.

What init! can do

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 with with_default_font.

  • io.env().read!(name) reads a declared environment variable.

update!: one input in, one model out

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

devices

The keyboard, mouse, text, and gamepad snapshot; see [input]

window

The window’s size, focus, whether it is minimized, and whether the user asked to close it

time

This cycle’s timing, including elapsed_seconds for animation; see [input-time]

messages

The results of tasks that finished since the last cycle; see [tasks]

capture

The status of any recording in progress

dropped, dropped_overflow

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!: draw the model

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.

Where each kind of code goes

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.

Closing the window

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.default uses Escape; change it with with_exit_key(ExitKey(key)), or turn it off with with_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.

Messages

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.

Keep the decisions pure

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.

Why it is shaped this way

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! or render!. In the C loop, one careless fopen or 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 model update! produced, so a screenshot or a recording shows what the game actually decided. That is also why render! 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.

Details: which effects are legal where

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 Time.now!) or queues output (printing)

init!, update!, and tasks; refused in render!

Draws

render! only

Waits on the outside world (files, network, databases, programs, sleeping, loading a resource from disk)

init!, where it delays startup, and tasks, where it pauses only that task; refused in update! and render!

Takes a screenshot of the next frame

Tasks only, since it waits for a frame that init! runs before

Starts a task

update! and tasks

Ends the app with io.exit!(code)

init!, update!, and tasks

Reads a startup fact (io.args!, io.entropy!, io.default_font!, io.env().read!)

init! only

Reads a value the host already has, in constant time: Window.scale!, Sound.is_playing!, Music.is_playing!, Music.length!, Music.time_played!

Every callback, render! included

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.

Further reading

  • [architecture] states these rules precisely and explains why RocRay chose them.

  • The App page of the API reference documents every configuration method and App.Input field.