Skip to content

A web game

The same Trait source that makes a desktop game can run in a web browser, on WebGPU. There is no C++ for the web: pack.mjs compiles your source and writes a page that runs it, and the options you give it are the whole setup.

What you need

  • Node.js (version 22 runs it) and uv (the Python tool runner the Trait compiler runs with), both on your PATH.
  • lusine in your game's folder, as for a desktop game (Your first game, steps 1 and 2).
  • For the panels only: npm ci run once in lusine/tools/lusine/web, which fetches the libraries the panels draw with.

Packing a page

From your game's folder:

node lusine/tools/lusine/web/pack.mjs --out web --extents 160x90 --seed mygame.board.on --pattern random:1:0.5 trait/mygame.trait

pack.mjs runs the Trait compiler for the browser (trait build webgpu: the program as data, the host's host.ts, and its shaders in WGSL), then writes the folder web/. Its options, which it saves in the folder's config.json:

Option What it sets
--out DIR the folder to write (required)
--extents WxH each lattice's size, a comma between lattices
--colour COLUMN the column of colours drawn over the whole canvas; by default the first one of a two-dimensional lattice
--viewports FILE.json several views in place of --colour, each a rectangle of the canvas showing a column (below); the JSON may also be given inline
--window WxH the canvas's shape, as a desktop window's size (1280x720 by default; with --colour alone, its board's)
--start NAME a procedure run once before the first step, as a desktop game's start (mygame.start)
--interpolate each frame drawn between the last two steps: a render reading lusine.render.table.frame.alpha runs every frame, told where between them the frame falls
--seed COLUMN --pattern P a column of true or false to fill at the start: random:SEED:DENSITY, glider:X,Y or empty
--rate N steps a second (60 by default), whatever the display's frame rate
--procedure NAME the procedure a step runs, if not the program's first
--decisions name=arm;... the arms of the compiler's tuning decisions to use
--in-flight N how many launches a frame may leave uncollected before it waits on the oldest's readback (default: as many as the unit's ring holds). A frame collects every launch the device has finished either way
--panels ROWS.json panels beside the board: the same rows a desktop game gives CoreRun::panels, as JSON
--timing the GPU timing overlay shown without being asked (below); ?timing=0 hides it
--zip also write DIR.zip, the same every time for the same input
--single also write DIR.html, one file with everything inside
--publish strip the comments from the page's scripts, leaving the code as it was (npm ci in lusine/tools/lusine/web first)
--board the sound board in place of a game's page (below): no --extents, --colour or --panels
--bench BENCH.json a bench page in place of a game's page: builds measured case by case (below); takes no other option but --out

A panels file is a list of rows, for example:

[{"kind": "plot", "panel": "life", "quantity": "life.totals.population", "steps": 600}]

A plot shows its quantity's name and current value above its chart, so it needs no value row beside it for the same quantity. A series chart has a legend naming its lines; a plot, with one line, has none.

A viewports file is a list of views, drawn in order, a later one over an earlier, the same as a desktop game's FrameLoopGame::viewports. A table game drawn by lusine.render.table, with its main picture and an inset:

[{"source": "lusine.render.table.screen.colour"},
 {"source": "lusine.render.table.inset.colour",
  "rect": {"x": 0.68, "y": 0.04, "w": 0.3, "h": 0.3}, "renderScale": 0.75}]

rect is the view's place on the canvas, in fractions of its width and height (the whole canvas if left out); view ({"centre": [x, y], "zoom": z}) pans and zooms it (the whole column fitted if left out). A picture a render draws is drawn at the view's own size: the page sizes it to the view's pixels, times renderScale (1 if left out; less draws fewer pixels, smoothed up), when the page opens and again whenever the window is resized, so the sizes you give --extents for those pictures don't matter.

On a high-density screen (a phone, an iPad, a Retina Mac) the picture is drawn at the view's size in CSS pixels, not the screen's pixels, and smoothed up the same way: a devicePixelRatio of 2 would otherwise draw four times the pixels. A view's density (1 if left out) is the most screen pixels a CSS pixel draws; set it to 2 for a Retina screen's full sharpness, at four times the cost. In full: the picture is the view's CSS pixels times the smaller of devicePixelRatio and density, times renderScale. The canvas itself is always at the screen's pixels. A desktop game's FrameLoopGame::viewports takes the same density, against the system's display scale.

A picture that takes too long to draw is drawn smaller for a while. Each frame, the page times every render (with the GPU's own clock where the browser has timestamp-query, else the wall clock), and lusine's resolution policy (library/lusine/resolution.trait) decides from those times how big to draw each view's picture next. If rendering stays over a frame budget (16.7 ms) for half a second, the picture steps down a rung, through 1, 0.85, 0.7, 0.6, 0.5 and 0.4 of its renderScale. If it stays under 70% of the budget for 3 seconds, it steps back up. There are at least 2 seconds between steps. lusine.render.table's draw runs the policy, so a table game has it without any setup. Another game calls lusine.resolution.control(); from its own render. The settings are the game's, set like any of its values in its start:

void start()
{
  lusine.resolution.settings.budget = 33.3 ms;   // a 30 fps game's frame
  lusine.resolution.settings.floor = 0.6;        // never below 0.6 of the renderScale
  lusine.resolution.settings.on = false;         // or: always draw at the renderScale
}

A step never reads any of this, so what the simulation computes is the same at any size. The antialiasing's kept frames start again after each change of size. Such a game would pack with:

node lusine/tools/lusine/web/pack.mjs --out web --start boules.start --interpolate --viewports views.json --extents 8x8,8x8 trait/boules.trait

A table, a message list and a panel shown while a bool holds take the same fields as on the desktop (table, shows, selected, slot, order, newest), with the kinds table, messages, slot and showing:

[{"kind": "table", "panel": "market watch", "table": "listings", "quantity": "bourse.listings.name", "selected": "bourse.selected"},
 {"kind": "table", "panel": "market watch", "table": "listings", "quantity": "bourse.listings.exchange", "shows": "bourse.exchanges.name"},
 {"kind": "showing", "panel": "book", "quantity": "bourse.scenario.shows_book"},
 {"kind": "messages", "panel": "desk", "table": "refusals", "quantity": "bourse.refusals.reason", "shows": "bourse.reasons.key", "newest": 8},
 {"kind": "slot", "panel": "desk", "table": "refusals", "slot": "listing", "quantity": "bourse.refusals.listing", "shows": "bourse.listings.name"}]

A click on a table's row is stored into the game's selected before the next step, as the desktop does; the page keeps no selection of its own.

What the page gives you

  • The board, drawn on a canvas from your colour column or your viewports each frame; the canvas takes the page's width.
  • A Pause button, a step counter, and a status line that names the graphics card, or says what went wrong.
  • Sound, if your program writes lusine.sound's block of samples: it starts at your first click or key press on the page (browsers allow no sound before one), and a Mute button appears. The samples are the same as on the desktop: the program synthesises them, as A desktop game describes.
  • Your panels, if you gave --panels.
  • Keys and pointers, as the desktop gives them (see A desktop game): where your program reads lusine.input.keys, each key pressed, repeating or released on the page is a row, by its place on the keyboard (KeyboardEvent.code); where it reads lusine.input.pointers, each mouse, pen or finger event on the canvas is a row, with the viewport it is over, where in it from 0 to 1, the browser's pressure, and a pen's tilt (in degrees). A finger or a pen has an id of its own from 1; the mouse's is 0. Escape and the F-keys stay the browser's. The page has no pause or step key, so Space and the arrows are your game's; the Pause button pauses it.
  • ?target=texture at the end of the page's address draws through an offscreen texture instead, for a browser that cannot show a WebGPU canvas directly.
  • ?timing at the end of the page's address (or --timing when packing) shows where the GPU's time goes, in a box over the canvas, redrawn four times a second. A click on the box hides the passes, and another shows them again. It shows:
  • frames and steps a second;
  • for each step and each render, its total GPU time: from the start of its first pass to the end of its last, the gaps between passes included;
  • the CPU's side: how long the frame loop ran each second and how much of that it spent waiting for results to come back from the GPU, and under each step and render, how long the CPU took to encode it;
  • under each total, how long its compute passes were actually busy and how many there were; the rest of the total is gaps between passes, where the GPU ran none of that step's work;
  • under each, its five slowest passes, each named as the compiled program names it: the pass's number, its shader's name (which says what the pass writes, lusine.render.table.draw: map lusine.render.table.screen.current), and, where one of the compiler's tuning decisions picks or sizes the pass, the decision and its arm in use. A pass that runs several times in a step (in a loop) counts once with its times added up;
  • each figure in milliseconds as an average over the last second, then the largest in that second. A step or render slower than that shows its last one, saying so.

The times come from the GPU's own timestamps (WebGPU's timestamp-query), read without making the page wait for them. While timing is on, each dispatch is its own compute pass, so each pass is timed alone (?timing=totals, or the T key while the page runs, leaves the passes as they are and shows only the totals and busy time, which shows what splitting them costs). On a page with no keyboard, such as an iPad, or one whose address can't take ?timing, the Timing button beside Pause does the same: each tap moves from off to every pass timed, to totals, and back to off. The same work still runs in the same order and gives the same values, which tests/web/timing_page.mjs checks. Chrome rounds timestamps to 100 microseconds unless chrome://flags/#enable-webgpu-developer-features is on; the box's first line gives the finest tick it has seen.

Where the browser has no timestamp-query (Safari and Firefox today), or refuses it, the box says so. It then shows each step's and render's wall-clock time instead, labelled as wall clock: from handing the work to the GPU until the GPU says it is done. That time includes the CPU's own work and anything already waiting ahead of it. A step with more passes than the timestamps can hold (2048) says timing incomplete: N of M passes, and its figures are short.

Opening it

  • The folder (web/) must be served by a web server: browsers do not load its parts from a file on disk. Any static host works (a web server, a site host, a Claude artifact). To try it yourself, run python -m http.server in the folder and open http://localhost:8000. WebGPU needs a secure page: https, or localhost.
  • The single file (--single, web.html) opens with a double-click.
  • The zip (--zip) is the form itch.io and a GitHub release take.

The sound board

Every sound lusine's library holds, one click each, synthesised in the browser the way a game does:

node lusine/tools/lusine/web/board.mjs

and open http://127.0.0.1:8554/ (--port N for another; --single also writes the board as one file). The board lists every instrument of the sound packs (the base pack's lusine.base.ui.* sounds, each a note at its own pitch or one you give, and its organ, held for a second and a half and then released), every tone and ambience in lusine.sound, a few struck pairs (steel on steel, boxwood on the ground, steel on sand) at three energies, and a rumble at two speeds, each with a Play button, plus a level slider and Stop all. Its rows are read from the program (tools/lusine/web/sound_board.trait), so a new instrument, recipe or ambience appears with no change to the page (a new pack's instruments need the program's note-on table and join for that pack). The documentation site has it too, under Live views. A change to how lusine sounds is accepted by listening here: its pull request names the rows to play. pack.mjs --board is the option that writes it.

Measuring a build

A bench page measures one or more builds of your game on whatever device opens it, with no one copying figures off the timing box. Write a bench spec, bench.json:

{
  "builds": {
    "table": ["--extents", "8x8,8x8,8x8", "--start", "tabletop.start", "--viewports", "views.json", "trait/tabletop.trait"]
  },
  "cases": [
    {"build": "table", "canvas": [1280, 720], "warmup": 120, "frames": 600},
    {"name": "table, one arm", "build": "table", "canvas": [2560, 1440], "pixelRatio": 2, "density": 2,
     "warmup": 120, "frames": 600, "decisions": {"spread_fold_tabletop_balls": "one"}, "twin": 30}
  ],
  "rounds": 1
}

and pack it:

node lusine/tools/lusine/web/pack.mjs --bench bench.json --out bench

Each build is the options and programs you would give pack.mjs for a game's page, as on its command line; its paths are read from the spec's folder. It is packed into bench/builds/NAME/, which also plays it on its own. A build takes no --out, --zip, --single, --board, --panels or --timing. A case names a build and how to run it:

Field What it sets
build the build to run (required)
canvas the canvas's size in pixels, [W, H], whatever the screen (required)
warmup animation frames run before measuring (required)
frames animation frames measured (required)
name the case's name in the results (the build and the canvas by default)
pixelRatio the canvas pixels a CSS pixel, in place of the screen's devicePixelRatio (1)
density, renderScale every viewport's, in place of the build's (1 and 1)
timing "passes", each pass timed alone, or "totals", the launches' totals only, as ?timing=totals ("passes")
decisions arms of the compiler's tuning decisions, over the build's own (none)
twin steps of the twin check (below); 0 runs none (0)

So a render's picture is drawn at its viewport's pixels times renderScale times min(pixelRatio, density) / pixelRatio, the same on every device, and the resolution policy is not run: no picture is drawn smaller while the case runs. rounds runs the whole list of cases that many times, so two arms listed one after the other take turns.

Serve the folder and open it (WebGPU needs https or localhost), then tap Run. Each case runs as the game's page would play it, on the display's frames, its steps by the step clock, timed as ?timing times it: the same work in the same order, giving the same values. After its warm-up the page waits for the GPU to finish and the timings to arrive, so its first measured frames catch up a few steps. ?bench=auto starts without the tap, and ?report=URL also posts the results there when it ends. The results come out three ways: window.lusineBench, as {"state": "ready" | "running" | "done" | "failed", "progress": 0..1, "results": ...}; a lusine-bench event on the window, the results as its detail; and the ?report post. A case's twin runs its build twin steps at the build's own arms and again at the case's, from the same start, each on a fresh device, and says whether every stored value of every launch matched word for word, as lusine tune's twin does.

The results ("schema": "lusine-bench/1"):

Field What it holds
state, error "done", or "failed" and why (the first case that failed, or the whole bench)
started, finished, rounds the run's times (ISO 8601) and the spec's rounds
environment userAgent, devicePixelRatio, screen; device, the adapter as a web tuning row keys it (api "webgpu", vendor, architecture, device, description); features (timestampQuery, subgroups) and every adapterFeatures; the lusine and trait commits packed; whether the screen's wakeLock was held
builds each build's hash (a sha256 of its packed files) and unit (its UNIT_ID)
runs one a case a round, in the order run (below)

A run holds its case's settings (case, build, hash, unit, round, index, canvas, pixelRatio, density, renderScale, warmup, frames, timing, and decisions, every decision's arm in force), and:

Field What it holds
path, why "timestamp" (the GPU's own timestamps), or "wall" and why there were none
resolutionNs the finest tick the timestamps showed
seconds, steps, framesPerSecond, stepsPerSecond the measured frames' time, and the steps launched in it
launches each step and render (kind, procedure): its device time total (first pass's start to last pass's end), busy (its compute passes' own time), cpu (the CPU's encoding), its passes (each its pass, label, as the timing box names it, and its time in a launch, the slowest first; none with "totals" or by wall clock), and incomplete, the launches timed short
frame the frame loop's time a frame (loop) and its waiting on readbacks (wait)
ns, passesNs each measured step's device time and busy time in nanoseconds, in step order: the raw figures, for lusine tune's rule to judge off the device
skipped, errors launches whose timestamps were not sampled, and the program's errors
twin where asked: steps, the arms inForce and measured, held, firstDiffers (the first launch that did not match, or null), refused, and a hash of each launch on each side
error where the case failed, why (the other fields are then left out)

Each time is in milliseconds as {"count", "mean", "median", "p90", "max"}, over the launches whose timings arrived while the case was measured: the median of an even count is the middle two's mean, and p90 is the nearest rank. By wall clock (where the browser has no timestamp-query), a launch's time is from handing it to the GPU until the GPU says it is done, one at a time a procedure, so not every launch is timed.

Measuring on an iPad

A Mac with an iPad plugged into it can run a bench on the iPad by itself: a pull request labelled ipad-bench, or the ipad-bench workflow run by hand, packs the bench (tests/web/ipad_bench.json unless the run names another), runs it in the iPad's Safari, keeps the results' JSON with the run, and comments a table of them on the pull request. The label is then taken off, so adding it again runs the bench again. It never runs on a push, since the Mac may be asleep.

tools/lusine/web/bench_ios.mjs does the run. It serves the bench over HTTPS on your network (the iPad is not localhost, and WebGPU needs a secure page), starts safaridriver, opens the page on the iPad with ?bench=auto, waits for it to finish, writes the results and stops everything. The page keeps the screen awake while it runs where Safari lets it. A locked iPad, Remote Automation off, no WebGPU or no timestamp-query each fail the run with a message saying so (--wall-ok, or the workflow's wall_ok, takes wall-clock figures instead).

Once, on the Mac:

  1. Install Node.js 22 and uv, and a GitHub self-hosted runner for the repository (the repository's Settings > Actions > Runners > New self-hosted runner, macOS), with the labels macOS and ipad. Run it as a service (./svc.sh install) so it starts with the Mac.
  2. Run safaridriver --enable (it asks for an administrator's password).
  3. Install mkcert (brew install mkcert), run mkcert -install, and make a certificate for the Mac's name on your network: mkcert -cert-file bench.pem -key-file bench-key.pem mac-mini.local (your Mac's name, from System Settings > General > Sharing, Local hostname; or its fixed address).
  4. Give the runner the three variables, in the .env file in its folder:
LUSINE_BENCH_CERT=/Users/you/bench/bench.pem
LUSINE_BENCH_KEY=/Users/you/bench/bench-key.pem
LUSINE_BENCH_HOST=mac-mini.local

and restart it. The page is served on port 8443 (--port), which the Mac's firewall must let in.

Once, on the iPad (iPadOS 26, whose Safari has WebGPU):

  1. Plug it into the Mac and tap Trust when it asks whether to trust this computer.
  2. Settings > Apps > Safari > Advanced: turn on Web Inspector and Remote Automation.
  3. Settings > Display & Brightness > Auto-Lock: Never, and leave it plugged in. A locked iPad stops the run.
  4. Trust mkcert's root: on the Mac, mkcert -CAROOT names the folder holding rootCA.pem; send that file to the iPad (AirDrop), install the profile it opens (Settings > General > VPN & Device Management), then turn it on under Settings > General > About > Certificate Trust Settings.
  5. Open https://mac-mini.local:8443/ in Safari once the runner has run a bench, or with bench_ios.mjs running by hand: a page without a certificate warning shows the setup works.

To run it by hand on the Mac:

node tools/lusine/web/pack.mjs --bench tests/web/ipad_bench.json --out build/ipad-bench
node tools/lusine/web/bench_ios.mjs --dir build/ipad-bench --out build/ipad-bench.json --table build/ipad-bench.md

Which browsers

The page needs WebGPU: recent Chrome and Edge, Safari 26, and recent Firefox. Where a browser has no WebGPU, the status line says so. Every program runs on any of them: where the browser offers subgroups (operations shared across a group of GPU threads), the page asks for them and runs each shader's subgroup form (shaders/NNN.subgroups.wgsl); where it does not, it runs shaders/NNN.wgsl, which gives the same values, more slowly.