Field notes

Chapter 19 · Mechanism

One scene, two canvases: the same game code on Canvas 2D and Skia

Every break room game is written once and drawn by two different renderers. A pixel conformance suite keeps them honest, and a contrast test keeps every theme playable, including themes that do not exist yet.

The Margin5 min read

Each break room game is written once and drawn by two different engines: the browser's own on the web, and a graphics library called Skia in the phone app. Getting the two to agree came down to small things like this one, so this chapter is the most technical of the set.

Draw a path in a browser canvas: moveTo(10, 10), then translate(50, 0), then lineTo(20, 20). The first point stays where it was. Only the second one moves, because the canvas maps each point through the transform at the moment you call it. Skia builds paths differently, and if you port a scene without knowing that, any shape built across a transform comes out wrong on the phone and right on the laptop.

That is one of about five differences like it that the phone host now papers over, and it is the reason the break room games have a conformance suite.

Two renderers, one game#

The brief put every game on every surface. On the web that means the browser's Canvas 2D. In the native app it means Skia. We were not going to write each game twice, so the real question was what a game is allowed to touch.

A scene (one game's rules, layout and drawing) lives in a platform-free package and draws only through an interface called GameCanvas2D. It is a strict subset of the canvas context, picked so that Skia can implement all of it: paths, arcs, ellipses and round rects, fills and strokes and clips, text with a measured width, save and restore and the usual transforms, gradients, dashes. Two extras earn their place because both hosts do them cheaply, an SVG path string made into a reusable path and an offscreen layer for backgrounds painted once.

The list of what a scene may not touch is enforced. No window or document, no Path2D, no reading the transform back, no pixel buffers, no filters. A test walks every file in the games package looking for them. There is a second, package-wide test that flags any use of something called window, even a local variable, which sounds fussy until you remember that a harmless-looking name is exactly how a browser global ends up in code that has to run on a phone.

Where Skia and the canvas spec disagree, canvas wins, since that is where the scenes were drawn first. Points map at the call. save and restore carry the whole drawing state. A negative line width or an alpha of 3 is ignored the way a browser ignores it. setTransform applies as a difference from the current matrix, so an earlier clip survives. Each frame starts with the matrix at base and alpha at 1, as the web host resets them. The scene itself runs on the JavaScript thread, records each frame into a Skia picture and hands the finished picture to the view in one call.

Seven games, three surfaces#

"Every game on every surface" cut both ways. The four new games had to reach the native app, and the old ones had to cross over. Stack and Catch the Tasks had only ever lived on the web, and they now sit on the phone's shelf too. Volley had only ever lived on the phone, and its rebuild, which has a chapter of its own, brought it to the web. Both shelves list all seven.

Volley's crossing had a small mess to clear first. Its figures, rig and physics existed twice, one copy per app, byte for byte, with a test whose only job was to notice when the two drifted apart. Now there is one copy in the shared package, the phone's old files are re-exports of it, and the twin test is gone because there is nothing left to compare.

Input got the same law as drawing: nothing works only one way. On the web every mechanic works by touch, mouse or keyboard. On the phone, touch alone is always enough, and the native extras are optional, each with its own switch in the game's settings: tilt to steer the plane, shake to scatter a Loose Ends board, a haptic tick on every snap and swap, a two-finger twist to turn a piece in The Week, a little parallax in the paper. Scenes don't receive raw events. They get actions tagged with where they came from, so The Week handles "rotate" once, whether it was the R key, a twist or a tap on a button.

Eight themes and whatever comes next#

Margin has eight themes, four light and four dark, and a hardcoded color looks right in about one of them. Scenes never see a hex value from the app. They get a GameTheme resolved from the current theme's semantic tokens (paper, ink, accent, the eight module inks as piece colors) and pick up the new one on the next draw after a theme change.

The theme test reads the theme registry instead of a list, so a theme added next month is tested with nobody editing anything. For each one it checks that ink and secondary ink both reach a contrast of 4.5 on paper and on raised surfaces, with a second, perceptual contrast check on top, and that every piece color reaches 3 on paper with at most a small nudge. A theme that would make a game unreadable fails CI before anybody plays it.

Pixel for pixel#

conformance.test.ts pushes eleven scripts of canvas calls through a reference canvas and through the real Skia host, running as CanvasKit in Node, and compares the frames pixel by pixel. The tolerance only covers antialiasing: at most 1% of pixels may be off by more than 48 out of 255, and the mean difference stays under 3.

Then it does the same with whole games. Each landed scene is driven for 90 frames with a seeded random source and a scripted drag, and the last frame is drawn on both hosts. Scenes are full of text, dashes and antialiased curves, so the bar widens slightly, to under 2% of pixels and a mean under 4.

The reference has holes of its own. CanvasKit's canvas emulation is older than two color syntaxes every browser now accepts, and it has no roundRect, textAlign or textBaseline. Those three are checked against the spec in separate tests.

What a frame costs#

On a phone, every call from JavaScript into Skia crosses into native code, and the count of those crossings matters more than nearly anything else in a frame. The budget test draws the busiest board we could build and counts. The first version made 1,441 calls per frame. After the paint setters were memoized, so a paint only changes when a style really does, it made 639.

The budget test fails at 900 calls, which would mean a bridge call had crept back into every shape.