Steve's Roc Apps roc-apps on GitHub

Safari Ride Chapters

Chapter 38

The platform

Every chapter so far has ended in a list of shapes. In this chapter, we follow the list onto the screen, and the viewer's keys back into the ride, the last of the five pieces. On the way, we see where the ride's effects live: in a small platform under the Roc code, which every app in this book shares.

canvas_app_runner.js in the browser host.zig inside the WebAssembly  calls advance with the input,  then computeFrame shapes_canvas.js in the browser  reads the shapes with  roc_glue.js, hands them over  returns where the frame's  list of shapes is the app's Roc functions  calls model_at_next_step,  then frame_shapes canvas_app_runner.js in the browser host.zig inside the WebAssembly  calls advance with the input,  then computeFrame shapes_canvas.js in the browser  reads the shapes with  roc_glue.js, hands them over  returns where the frame's  list of shapes is the app's Roc functions  calls model_at_next_step,  then frame_shapes
One tick, in order

An app is a value §

The “Platforms, packages and modules” chapter showed the app's last line: WasmApp.program takes SafariApp.canvas_app, the whole ride as one value. Here's that value:

SafariApp.roc
canvas_app : CanvasApp.CanvasApp(SafariApp.Model)
canvas_app = {
	size: { width: Screen.width.to_f64(), height: Screen.height.to_f64() },
	# The ride's motion is written per step, and thirty steps a second is the
	# speed it is meant to look like, chosen by eye.
	frames_a_second: 30,
	init: initial_model,
	model_at_next_step: CanvasApp.untimed_model_at_next_step(model_at_next_step),
	frame_shapes: |m| FrameView.frame_view_at(m.tour.route(), m.tour.instant()).leaned_shapes(),
	sounds: |_m| 0,
	tones: [],
	title: "Safari",
}

An app is a record. Most of its fields are plain values: the screen's size, thirty frames a second, the first model, and a title. The ride is silent, so sounds returns 0 and tones is empty. model_at_next_step moves the model on a step, and frame_shapes returns the shapes of the model's frame.

Every app's step also takes the seconds the step covers, which WasmApp works out from the frame rate. The ride counts steps, so one function fits its step to that shape:

lib/CanvasApp.roc
untimed_model_at_next_step : (model, Input.Snapshot -> model) -> (model, Input.Snapshot, F32 -> model)
untimed_model_at_next_step = |step| |model, input, _seconds| step(model, input)

In the annotation, each part in parentheses is the type of a function. untimed_model_at_next_step takes a function of a model and an input snapshot, and returns a function that takes the seconds too and passes the first two to step. The seconds stop there, so the ride runs on steps alone.

What the platform calls §

WasmApp.program turns the app into the functions the page's platform calls, the same for every app:

lib/WasmApp.roc
program : CanvasApp.CanvasApp(model) -> {
	init : {} -> Box(model),
	model_at_next_step : Box(model), U32, U32, U32, U32, F32, F32, F32 -> Box(model),
	frame_shapes : Box(model) -> List(Shapes.Shape),
	release : List(Shapes.Shape) -> {},
	sounds : Box(model) -> U32,
	tone_count : {} -> U32,
	tone_freq : U32 -> U32,
	tone_ms : U32 -> U32,
	width : {} -> U32,
	height : {} -> U32,
	fps : {} -> U32,
}

Every one of the eleven functions has the thin arrow, so all of them are pure. A function such as width : {} -> U32 takes the empty record, {}, and returns a value from the app alone.

The model travels as a Box(model): the model behind a pointer, which the platform holds and only Roc reads. The input travels as seven numbers: the keys held and struck, the mouse buttons held and struck, the pointer's place across and down, and the wheel. WasmApp.program builds them back into a snapshot, with Input.of for the whole and Mouse.of for the mouse.

The person watching §

Mostly, Safari Ride is a screensaver: you sit back and watch it. You can also take the controls. Click the ride to give it the keyboard. Space pauses it. Held down, Right and Left move it forward and back, one step at a time while it's paused, and four at a time while it plays. J jumps to the next segment of the road, and R starts the ride over, running.

The snapshot goes to the app's step, SafariApp.model_at_next_step. The step takes the model, which is the tour and whether it's paused, and the snapshot, and returns the next model. Here's the part that pauses and steps:

SafariApp.roc, in model_at_next_step
# ...
motion = if input.key_pressed(KeySpace) {
	match m.motion {
		Playing => Paused
		Paused => Playing
	}
} else {
	m.motion
}
steps_a_frame = match motion {
	Paused => Steps.one_step
	Playing => Steps.of(4)
}
moved =
	if input.key_down(KeyRight) {
		m.tour.steps_forward(steps_a_frame)
	} else if input.key_down(KeyLeft) {
		m.tour.steps_back(steps_a_frame)
	} else {
		match motion {
			Paused => m.tour
			Playing => m.tour.step_forward()
			# ...

key_pressed returns whether a key went down since the last step, so one tap of Space pauses the ride once. key_down returns whether a key is held, so holding Right keeps the ride moving. The step reads the snapshot every time it runs, so each control answers on the next step.

The host §

The platform's own code is a host and a little JavaScript. host.zig, in Zig, runs inside the WebAssembly beside the app, holds the model, and calls the app's Roc functions. canvas_app_runner.js, in the browser, keeps time and gathers the input. For each tick it calls the host's advance, which steps the model, and when the browser is ready to draw, computeFrame, which returns where the frame's list of shapes sits in memory. When the browser falls behind, the runner steps up to four times before it draws once.

The runner reads the shapes out of memory with roc_glue.js, a reader generated from the platform's types, and shapes_canvas.js paints them on the page's canvas. On the next call to computeFrame, the host hands the last frame's list to Roc's release before it asks for a new one. release is pure too: it takes the list and returns {}. Roc frees a value when its last reference goes, so passing the list to release frees it, in the app's own compiled code, which holds the layout of a Shapes.Shape.

The runner and the painter come to under 700 lines of JavaScript, shared by every app on the platform, and the ride reaches them as a list of shapes.

A frame is a list §

A frame is one flat list of shapes, painted in order, so a later shape covers an earlier one. Two tags in that list are marks. Blend changes how the shapes after it mix with what's painted already. The ride uses it to add a light's glow to the colors under it. View sets the space the shapes after it are drawn in: a turn, a shift and a zoom, applied to their points. Here's how the whole frame leans with the rider:

FrameView.roc
## The whole frame turned by the camera's roll.
##
## The whole frame leans, backdrop and all, so the world banks into a turn:
## everything is drawn in one space, which turns about the middle of the
## screen by the roll, the other way round.
leaned_shapes : FrameView -> List(Shapes.Shape)
leaned_shapes = |view| framed_shapes(view, Screen.leaned_by(view.roll))
Screen.roc
## The space the whole frame is drawn in when the camera rolls by `roll`:
## the screen turns by minus the roll about its middle.
leaned_by : Angle.Lean -> Shapes.Space
leaned_by = |roll|
	Shapes.world_space({
		target: middle,
		offset: middle,
		rotation: 0.0 - roll.to_degrees(),
		zoom: 1.0,
	})

leaned_by returns a space that turns the screen about its middle, by the roll in the opposite direction. leaned_shapes puts the whole frame inside that space, backdrop first, so the sky and the grass lean with the road.

So the ride is a value, a step and a frame. The platform calls them, and everything with an effect, from the timing to the canvas, happens on the platform's side of the line.