How roc-apps/canvas_apps is put together: one set of files per app, running as a
native program on roc-ray and as a page in a browser.
The six apps cover a few different kinds of program: three arcade games, a world you fly around with a camera, a pixel paint program, and a movie you can scrub. Snake is the example throughout, because it is the smallest.
An app is a Roc value. Two runners know how to run one:
CanvasAppRunner.roc on roc-ray, and canvas_app_runner.js in a browser.
Between the app and either runner sits lib/, a Roc package every app shares.
digraph {
rankdir=LR
node [shape=box style=rounded fontsize=11]
subgraph cluster_app {
label="snake/ (one app)" fontsize=10 style=dashed
game [label="SnakeApp.roc\na CanvasApp value"]
rules [label="Rules.roc\nSnake.roc\nBoard.roc"]
draw [label="SnakeDraw.roc"]
game -> rules
game -> draw
}
lib [label="lib/\nCanvasApp Shapes Brush\nInput Keys Mouse Math\nColor Camera Font Random"]
native [label="native/CanvasAppRunner.roc" shape=box]
web [label="web/canvas_app_runner.js" shape=box]
ray [label="roc-ray\n(window, GPU, keyboard)" shape=box style="rounded,filled" fillcolor="#eef"]
page [label="a browser\n(canvas, events, WebAudio)" shape=box style="rounded,filled" fillcolor="#efe"]
rules -> lib
draw -> lib
game -> lib
game -> native [style=dashed label=" handed to "]
game -> web [style=dashed label=" handed to "]
native -> ray
web -> page
}
Six apps are built this way: snake, pong, breakout, camera,
workshop, trick_or_treat.
lib/CanvasApp.roc is the type. An app is a record of eight fields over its
own model:
CanvasApp(model) : {
size : { width : F64, height : F64 },
fps : I32,
init : model,
advance : model, Input.Snapshot, F32 -> model,
frame : model -> List(Shapes.Shape),
sounds : model -> U32,
tones : List({ freq : I32, ms : I32 }),
title : Str,
}
advance takes one step, given what the keyboard and pointer look like now and
how many seconds the step covers. frame answers what to draw. sounds
answers a bit per tone the last step set off. The app imports no platform, so
the same value is handed to either runner.
An app names its own value out loud. snake_web.roc and snake_native.roc are
five lines each:
program = WasmApp.program(SnakeApp.canvas_app) # the page
program = CanvasAppRunner.program(SnakeApp.canvas_app) # roc-ray
A list of shapes, in the app's own coordinates. Four shapes and two marks:
digraph {
rankdir=LR
node [shape=record fontsize=11]
s [label="{Shapes.Shape|{Poly|points + Brush.Fill}|{Disc|centre, radius, Brush.Fill, clip}|{Rect|box + Brush.Fill}|{Image|cols x rows of Rgba}|{Blend|Over \\| Add}|{View|Screen \\| World(camera)}}"]
}
A mark is not drawn. It changes how the shapes after it are painted, until the
next mark of its kind. Blend(Add) lights rather than paints; View(World(c))
puts the shapes after it in a camera's world, and View(Screen) brings them
back to the window. A frame starts on the screen, painting over.
A Brush.Fill is a flat colour or one of six gradients, each carrying its own
geometry, so a fill means the same thing to every painter.
Input.Snapshot is a value: which keys are held, which were struck since the
last tick, and the pointer's position, buttons and wheel. Each runner builds
one — CanvasAppRunner from roc-ray's Devices.Snapshot, canvas_app_runner.js
from the browser's keyboard and pointer events — and a test writes one down:
Input.none.with_key_down(KeyW)
native/CanvasAppRunner.roc opens a window at the app's size, paces to its fps,
converts roc-ray's snapshot into an Input.Snapshot, and paints the frame.
Painting walks the frame once and cuts it into runs at every mark, because
raylib takes a blend and a camera as scopes. Each run is drawn inside the
scopes its marks named. A shape with a gradient goes through one fragment
shader (lib/BrushGlsl.roc) that does the brush arithmetic on the scene
position; a flat colour is drawn directly. The whole frame is painted into a
render texture at twice the window and scaled back down, which is where the
anti-aliasing comes from.
canvas_apps/native.sh snake a binary
TARGET=x64win canvas_apps/native.sh snake for Windows
Three files reach the browser, plus the app's wasm:
digraph {
rankdir=TB
node [shape=box style=rounded fontsize=11]
html [label="index.html\nwindow.SHOW = { wasm, loading }"]
glue [label="roc_glue.js\nreads a frame out of wasm memory" style="rounded,filled" fillcolor="#ffe"]
wire [label="shapewire.js\npaints shapes onto a canvas"]
run [label="canvas_app_runner.js\nclock, keyboard, pointer, speaker"]
wasm [label="snake.wasm\nthe app + the platform host" shape=box3d]
html -> glue -> wire -> run [style=invis]
run -> wasm [label=" advance(7 numbers)\n computeFrame()"]
run -> glue [label=" frame()"]
run -> wire [label=" paint()"]
glue -> wasm [label=" reads memory" style=dashed]
}
The page loads them in that order; each defines one name the next can see.
The clock is the app's. Elapsed time is banked and steps are taken as they
fall due, so the app runs at its own fps whatever the display does, and one
animation frame may take several steps or none.
The speaker reads tones for each tone's pitch and length, and plays the
ones sounds reports, one WebAudio oscillator each, with a pip per tone drawn
in the corner.
canvas_apps/build.sh snake a page
node canvas_apps/web/page_check.mjs snake run it headlessly
node canvas_apps/web/camera_check.mjs the camera, on both ends
The page's half of Roc is a platform in web/platform/. It declares what it
needs from an app, and what a frame is:
frame : Box(model) -> List(Frame.Shape)
web/platform/Frame.roc spells that type out. Because it is a structural
union, lib/Shapes.roc's Shape unifies with it by shape rather than by name,
so an app hands its frame over unchanged.
roc glue reads the compiler's own type table for that platform and runs a
spec over it. glue/JsGlue.roc is the spec: it emits one JavaScript reader per
type, each taking a DataView over the wasm memory and a byte offset, at the
32-bit layout wasm uses.
digraph {
rankdir=LR
node [shape=box style=rounded fontsize=11]
plat [label="web/platform/main.roc\nweb/platform/Frame.roc"]
roc [label="roc glue" shape=ellipse style="filled" fillcolor="#eee"]
spec [label="glue/JsGlue.roc\nthe spec"]
table [label="the compiler's\ntype table" shape=note]
out [label="roc_glue.js\ngenerated" style="rounded,filled" fillcolor="#ffe"]
plat -> roc
spec -> roc
roc -> table [dir=back style=dashed]
roc -> out
}
build.sh runs it on every build, with the same compiler that builds the wasm,
and copies the result beside the page:
"$ROC" glue "$HERE/../glue/JsGlue.roc" "$OUT" "$HERE/web/platform/main.roc"
The output is generated, never checked in and never edited.
A reader per type. Scalars are one DataView call:
const read_t38 = (view, at) => view.getFloat64(at, true);
A record is its fields at the offsets the compiler committed to — note that those are the compiler's order, not the source's:
// a record
const read_t41 = (view, at) => ({ a: read_t38(view, at + 0), b: read_t38(view, at + 8),
g: read_t38(view, at + 16), r: read_t38(view, at + 24), });
A list is a pointer and a length, with the element stride the compiler gave:
const read_t34 = (view, at) => {
const start = view.getUint32(at, true);
const length = view.getUint32(at + 4, true);
const out = [];
for (let i = 0; i < length; i++) out.push(read_t33(view, start + i * 208));
return out;
};
A tag union is a discriminant and a payload at one address:
const read_t33 = (view, at) => {
switch (view.getUint8(at + 200)) {
case 0: return ({ tag: "Blend", value: read_t34(view, at + 0) });
case 1: return ({ tag: "Disc", value: read_t35(view, at + 0) });
...
}
};
Alongside the numbered readers, each provided function's result gets a name taken from the function, so a page binds a name rather than a position:
return { read_t1, ..., init: read_t25, advance: read_t31, frame: read_t34, ... };
The runner asks the host where the frame is, then reads it:
frame: () => {
const at = exports.computeFrame();
return RocGlue.frame(new DataView(exports.memory.buffer), at);
},
computeFrame() is the effect: it asks Roc for a new frame and answers the
address of the Roc list. The DataView is built after that call and on its own
line, because asking for a frame can grow wasm memory, and growing it detaches
every view over the old buffer.
What comes back is plain JavaScript — { tag: "Disc", value: { x, y, r, fill,
clip } } — and shapewire.js paints it.
The platform also requires a release, which every app answers the same way:
release : List(Shapes.Shape) -> {}
release = |_frame| {}
The host holds one frame at a time. Before asking for the next, it hands the
last one back through roc_release and Roc drops it, using the layout it
already has. The host keeps no knowledge of what a shape looks like.
canvas_apps/
snake_web.roc snake_native.roc the two apps, five lines each
snake/ the app: its rules, its drawing, its page
lib/ the Roc every app shares
native/CanvasAppRunner.roc the roc-ray runner
web/
platform/main.roc what the page needs from an app
platform/Frame.roc what a frame is
platform/host.zig the wasm exports
canvas_app_runner.js clock, input, speaker
shapewire.js painting
page_check.mjs camera_check.mjs
build.sh native.sh
glue/JsGlue.roc the glue spec
page_check.mjs runs a built page the way a browser would, against a canvas
that records instead of painting. KEYS=3:Space presses keys and
DRAG=80,80,500,500 drags the pointer, so an app that waits for input is
driven. It reports frames, how many of them were distinct pictures, canvas
calls and fills, and a hash of everything drawn, so one run can be compared
with another.
camera_check.mjs hands shapewire.js a view mark and checks the canvas
matrix it builds against the same map written the geometric way —
screen = zoom · R(rotation) · (world − target) + offset — at four cameras and
four points.