I have been working on creating (or porting) apps that run both in the browser as canvas apps and as native apps using roc-ray.
https://roc.lynrummy.com/breakout/ is a example.
![]()
Everything basically goes through this interface:
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,
}
see https://github.com/showell/roc-apps/blob/master/canvas_apps/lib/CanvasApp.roc for more details
In order to prove the concept, I took several examples from the roc-ray repository that @Luke Boswell published. For example, the Breakout game originally came from https://github.com/lukewilliamboswell/roc-ray/tree/main/examples/breakout
It was not my goal for Luke's examples to be pulled verbatim into my repo and "just work", but I never needed any serious modification either.
It was my goal that the code, once modified, runs identically in both the browser and on roc-ray, with just different drivers for the most part. In other words, the actual app code does not need to be tweaked once you either shim it or modify it to work with the CanvasApp code.
It's pretty versatile for such a small system. Canvas apps can include arcade games (I also pulled Pong and Snake from Luke's repo) or little animated shorts (Safari and Trick-or-Treat are mine) and miscellaneous other apps.
You can see the web versions running at https://roc.lynrummy.com/. It's still under very active development. The code is at https://github.com/showell/roc-apps/tree/master/canvas_apps
The cool thing here is that Roc doesn't really flinch when it comes to supporting two similar but different paradigms. The roc-ray paradigm uses Roc effects pretty concretely, whereas the browser side is more about using Roc staying in the pure function space and letting a thin JS layer mutate the world.
roc-ray app needs
native/CanvasAppRunner, which touches the platform and so cannot live in a package
I think you can do this now, we landed https://github.com/roc-lang/roc/pull/11079
By convention the app root is typically named "main.roc" which is why all my examples have the files in a directory alongside it. That way the tooling like LSP looks up the tree until it finds main.roc and you don't need to add a flag for that
Same convention for packages and platforms too, the "root" is assumed to be main.roc unless you provide a flag
Not saying you should change it, just explaining why I structure things that way
Luke Boswell said:
Same convention for packages and platforms too, the "root" is assumed to be
main.rocunless you provide a flag
Ah, good to know. I relayed your feedback to Claude, and now each app directory will have a main.roc for roc-ray and a web.roc for web. That's still in progress as I write this, but it shouldn't take long.
Here's an example of making the code work on the web:
# Breakout, as a page: the wasm module a browser runs. main.roc beside it
# is the same app on roc-ray.
app [Model, program] {
pf: platform "../web/platform/main.roc",
lib: "../lib/main.roc",
}
import lib.WasmApp
import BreakoutApp
Model : BreakoutApp.Model
program = WasmApp.program(BreakoutApp.canvas_app)
It's pretty simple. :) web.roc
The landing page now has screen captures. :)
Love the screen captures... I think it's really compelling and inviting
Did you use roc-ray's builtin screen capture utilities for that?
Luke Boswell said:
Did you use roc-ray's builtin screen capture utilities for that?
No, I already tooling from before using roc-ray: https://github.com/showell/roc-apps/blob/master/canvas_apps/web/mini_canvas.mjs
Claude says I would need to install xvfb to use roc-ray's screen capture. It's a pretty small dependency, but I'm trying to avoid dependencies where I can.
Last updated: Sep 24 2026 at 15:59 UTC