Blog

Case study: moving sabacc.starwars.run from Lit to Gyral

sabacc.starwars.run is a browser version of Sabacc, the card game from Star Wars: get your hand closest to ±23, survive the shift, take the pot. It has two views of one game. The classic table is a static, crawlable page. The 3D table draws the same game with Three.js. You can play alone against the computer, or with friends at a table a server runs. The rules live in a pure engine written with Effect.

Until 7 October 2026 its interface was built with Lit 3. Now it is built with Gyral, on 0.3.1 from npm. This post covers why it moved, what worked, what didn't, what Gyral changed in 0.3.1 as a result, and what the move cost in bytes.

Sabacc is my project too, so read this as a field report, not an independent review. The work happened in the game's own repository, by the team building it, from packed Gyral tarballs, because Gyral 0.3 wasn't on npm yet; since 11 October it installs 0.3.1 from npm. Their feedback came back as a written report, which I triaged like any other. The numbers below were measured, and I include the ones where Gyral comes out worse.

Why they moved

Nothing was wrong with Lit here. The Lit version worked, and it was smaller. The move had two reasons.

The first was Gyral itself. Gyral 0.3 had just replaced Lit with its own view layer, and the only apps on it were this site and a demo store. Sabacc's migration plan put it plainly: "Sabacc becomes a real app that tests Gyral 0.3." A game has things a docs site doesn't: a WebGL scene, a store fed by a WebSocket, timers that pace the opponents' turns, and two pages sharing one set of components.

The second was fit. The game was already built the way Gyral wants an app built. The engine is pure, every random number goes through a seeded service, and side effects run in one place, the store. Gyral's pure reducers and effects as data carry that discipline into the interface.

They targeted 0.3 directly and kept the scope to the interface. The engine, the wire protocol, the server and both stores (solo and multiplayer) didn't change, and an architecture lint keeps @gyral/* imports out of those layers. The whole move landed on 7 October, from vendoring the 0.3.0 tarballs to running on a 0.3.1 prerelease. Across src/ and the tests it touched 33 files (1,792 lines added, 1,055 removed). At the end, all 153 tests passed. Their scripted browser run played the classic table, the 3D table, a shift and a multiplayer game, 29 runs in all, without a console error. Its screenshots matched the Lit version in both themes, on desktop and phone.

What worked

The compiler, ESLint and the runtime agree

Gyral checks template rules in three places: the Vite compiler fails the build, the development runtime throws on first render, and @gyral/core/eslint underlines the markup in the editor. All three run the same rule set. Sabacc added ESLint with only Gyral's recommended config, next to its own architecture lint, and checked that it caught .value= before going further. Their frontend conventions now say:

Template rules are checked three times: ESLint in the editor (npm run lint:templates), the runtime in dev and tests, and the compiler in vite build (a break fails the build).

This matters most during a migration. A developer coming from Lit writes @click=${…} and .value=${…} out of habit, and each one is reported the same way, with its fix, wherever it first shows up.

provideDrivers made it incremental

The plan began with a spike: convert one component and stop for review. They picked <sabacc-controls>, the betting and card buttons that both views share, because it had the most closures. It became a Gyral component while both views were still Lit.

Gyral components reach the outside world through drivers, and provideDrivers(element, drivers) binds drivers for everything under an element. So the Lit parent bound the game store to the Gyral controls inside it. From sabacc's src/components/sabacc-table.ts at the spike commit (trimmed and reformatted):

@customElement('sabacc-table')
export class SabaccTable extends SignalWatcher(LitElement) {
  // …
  override willUpdate(changed: PropertyValues<this>): void {
    if (this.store === undefined) return;
    // Gyral components below (the controls) reach this store through the table driver.
    if (changed.has('store')) {
      this.unprovide?.();
      this.unprovide = provideTable(this, this.store);
    }
    this.playback.sync(this.store.last.get());
  }
}

provideTable is their one-line wrapper around provideDrivers. The controls' outputs, such as "skip ahead", reached the Lit parent as a DOM event (@gyral-output=${this.onControls}). From there they converted the rest one component at a time. Today each page binds the store once, before any component loads, and the browser tests bind a real store on a host element the same way. There are no fake stores anywhere.

The cost: until Lit was removed, both view layers shipped. During the spike the shared chunk grew by 13.1 KB gzipped. That is fine on a branch, but it's a reason not to linger halfway.

Models tested without a DOM

A Gyral component's spec is plain data and functions, so @gyral/testing can step it like a state machine. Commands are data too, so a test can check the move a button sends without a store, a DOM or a fake. From sabacc's test/unit/controls-model.test.ts (trimmed and reformatted; moves picks the game commands out of a step's command list):

it("sends each button's move to the table, and nothing else", () => {
  const s = { amount: MIN_BET };
  expect(moves(step(spec, s, { _tag: 'Deal' }, props()).commands)).toEqual([Command.StartHand()]);
  expect(moves(step(spec, s, { _tag: 'Fold' }, props()).commands)).toEqual([Command.Fold()]);
  expect(moves(step(spec, s, { _tag: 'Pass' }, props()).commands)).toEqual([Command.Pass()]);
});

In the Lit version, the opponents' turns were paced by a setTimeout inside a class that a Lit reactive controller wrapped. Now the playback is a pure model, and its pauses are @gyral/time delay commands, which tests run under fake timers. Model tests cover only the controls so far; the tables are still tested in Chromium.

An accurate migration guide

When the first 0.3.1 prerelease arrived, sabacc followed the upgrade notes and found them accurate. Swapping the tarballs alone passed every check before any app change. Then they took up the new options one by one. Later prereleases made 0.3.1's breaking changes, the one-time exception before Gyral's API is locked. The one every app meets is define: define<State, Msg>(tag, spec) became define<State, Msg>()(tag, spec). I tried that build on a copy of their code first. The type checker listed every call site, and the whole fix was 16 lines in 9 files. Every change in 0.3.1 is listed in the core changelog.

The Three.js stage as a custom element

The 3D table is a WebGL scene with a render loop, textures and GPU buffers. It has to be torn down when it leaves the page. In Lit, the 3D view's element owned it: it built the stage in firstUpdated and disposed of it in disconnectedCallback.

The first Gyral version mounted the stage through a Gyral hook, a function the view attaches to an element. Hooks in 0.3.0 had no cleanup, so the stage checked isConnected on every animation frame to notice it had been removed. They reported this. I asked whether a custom element would work instead of a hook, and their answer was yes, and cleaner. <sabacc-stage> is a plain custom element with one property in and one DOM event out. From sabacc's src/scene/sabacc-stage.ts (trimmed and reformatted):

export class SabaccStage extends HTMLElement {
  #view: StageView | undefined;
  #stage: Stage | undefined;

  set view(view: StageView | undefined) {
    this.#view = view;
    this.#draw();
  }

  connectedCallback(): void {
    this.#draw();
  }

  disconnectedCallback(): void {
    this.#stage?.dispose();
    this.#stage = undefined;
  }

  #tell(detail: StageEvent): void {
    this.dispatchEvent(new CustomEvent(STAGE_EVENT, { detail, bubbles: true }));
  }
  // #draw() builds the Three.js stage on first use and hands it the view.
}

The 3D view's template, in src/scene/sabacc-3d.ts, sets the property and names an intent for the event (reformatted):

html`<sabacc-stage
  class="stage"
  role="img"
  aria-label="3D Sabacc table. Your moves and the table's state are in the panels."
  .view=${stage}
  data-intent=${i.Stage}
  data-intent-on="stage-event"
></sabacc-stage>`;

Picks, hovers and "this browser has no WebGL" arrive as one Stage message. The browser now tells the stage about every connect and disconnect, so the per-frame check is gone. A new browser test removes the 3D view and checks that the stage is torn down at once. The hook version noticed only on the next frame.

That exchange changed Gyral's docs. They now lead with this pattern for widgets with a lifecycle. Hooks gained teardown in 0.3.1 too, for small behaviours like a timer on one element: defineHook({ client, dispose }).

Watching a store Gyral doesn't own

The game state stays in sabacc's store, and the store runs the engine. It has to: a move needs Effect's seeded random service, and Gyral reducers are pure and synchronous. So Gyral handles only the interface loop, and the store has to be watched from outside.

In 0.3.0 the store kept its values in TC39 signals through signal-polyfill, and the table driver watched them. This is their watch() from src/components/table-driver.ts, as of the 0.3.0 migration (reformatted):

/** Emit a snapshot now and after every change to the store's signals, until aborted. */
const watch = (
  store: Store,
  signal: AbortSignal,
  emit: (s: TableSnapshot) => void,
): Promise<TableSnapshot> =>
  new Promise(() => {
    const current = new Signal.Computed(() => snapshotOf(store));
    const watcher = new Signal.subtle.Watcher(() => {
      // Signals can't be read inside the notification: read and re-arm in a microtask.
      queueMicrotask(() => {
        if (signal.aborted) return;
        watcher.watch();
        emit(current.get());
      });
    });
    watcher.watch(current);
    emit(current.get());
    signal.addEventListener('abort', () => watcher.unwatch(current), { once: true });
  });

It is a careful piece of code. A watcher's notification may not read signals, so it re-arms and reads in a microtask. A move that sets several values in one task therefore arrives as one snapshot. The promise never resolves, which keeps the command open while the component is connected. The abort listener unwatches when Gyral cancels it.

Every app with state Gyral doesn't own writes some version of this, so 0.3.1 added subscription(name, (emit, ctx) => unsubscribe): a streaming driver that cleans up when its component disconnects and ignores emits after that. A component asks for it in its subscriptions(state, props), also new in 0.3.1, so Gyral starts the watch when the component connects and stops it when the component leaves. The recipes in State Gyral doesn't own, signals included, are based on this watch().

Sabacc's current version uses it. They also replaced the signals with a small Cell of their own (get, set and subscribe, about 30 lines), because nothing needed signals any more:

export const tableWatch = (store: Store): Driver<undefined, TableSnapshot> =>
  subscription<TableSnapshot>(WATCH, (emit) => {
    let queued = false;
    const changed = () => {
      if (queued) return;
      queued = true;
      queueMicrotask(() => {
        queued = false;
        emit(snapshotOf(store));
      });
    };
    const unsubscribe = cellsOf(store).map((c) => c.subscribe(changed));
    emit(snapshotOf(store));
    return () => unsubscribe.forEach((off) => off());
  });

That excerpt is from src/components/table-driver.ts on sabacc's main branch, on Gyral 0.3.1, reformatted. subscription() returns a driver, which provideDrivers binds under the page's <body> behind a typed token from defineDriver. The promise that never resolves, the abort listener and the aborted check are gone. A move now goes from an intent to a reducer to a play(move) command, then store.dispatch, a cell change, a microtask, one snapshot, and a TableChanged message back into every component watching.

Rough edges, and how 0.3.1 fixed them

Their report listed what got in the way. Every row below was fixed after it, except the input.detail docs fix, which another tester's report prompted.

What they hit in 0.3.0 What 0.3.1 does
A Lit parent could hear a Gyral child's outputs only through the event name gyral-output, which core didn't export. Exports OUTPUT_EVENT and its types, and documents listening from other frameworks.
emit() wasn't checked against a component's outputs; they added satisfies by hand. const emit = outputs<Out>() types every output, at no run-time cost.
Hooks had no cleanup, so the WebGL stage polled isConnected. The docs lead with a custom element for widgets with a lifecycle; defineHook({ client, dispose }) covers small behaviours.
Object props needed a Standard Schema, and the docs didn't say whether a prop kept the object it was given. They wrote identity-preserving schemas to be safe. prop.value() takes a plain type guard. The docs say a property set keeps the original object, and production builds drop the check.
SVG fragments couldn't be templates of their own, so each card face drew every variant and hid the unused ones with display="none". The svg tag is back (about 85 B gzipped, only for apps that use it), with stricter checks inside <svg>.
A click's messages passed through their store's microtask, so browser tests looped await Promise.resolve() twenty times before settled(). settled() waits until messages stop arriving, however many microtasks the chain takes, and rejects if they never stop.
The docs said input.detail held only a child component's output, so the stage's intent parser read event.detail itself. The docs now say detail is set for every CustomEvent, and the stage's parser reads detail.
A client-only app still carried the hydration loader and Vite's preload helper. gyralVitePreset({ clientOnly: true }) leaves them out, and delay runs on a delay-only driver, so an app that only delays bundles no ticks.

Two of these deserve a candid footnote. The prop concern never caused a bug: Gyral always kept the object, and the docs simply didn't say so. And when I investigated the test loops, sabacc's tests passed on 0.3.0 without them. But nothing promised that, so 0.3.1 made it the definition of settled().

svg is a reversal, too. Gyral 0.3.0 dropped svg templates on purpose, with a note that they could return if a real app needed them. Sabacc's card faces were that app.

Measuring the client-only option turned up a bug of Gyral's own. The build scanned every module's text to decide which optional features to bundle. A function named raw in Effect and the word "states" in comments in Effect and Three.js kept two features in a bundle that never used them. That cost 0.9 KB per page. The scan now reads only the app's own modules and packages that depend on Gyral, with comments excluded. That was fixed before sabacc's first 0.3.1 build.

What it cost: bytes per page

Every figure below is a page's JavaScript and CSS, compressed with gzip at level 9 (Node's zlib, one file at a time) and summed over every file the page loads: its module script, its modulepreload links and stylesheet, and their static imports. KB means 1,000 bytes. Both pages are client-only. The 0.3.0 builds also contain Gyral's lazily loaded hydration and invoker fallback chunks, which are left out because these pages never fetch them. For this post I rebuilt every row from sabacc's history, each commit with its own vite build; the older rows reproduce the numbers measured at the time to the byte.

Build Classic page 3D page
Lit 3, Vite 7 (the original baseline) 84.5 KB 239.3 KB
Lit 3, Vite 8 (the same bundler as every Gyral build) 82.7 KB 238.1 KB
Gyral 0.3.0, first complete migration 90.4 KB 246.5 KB
Gyral 0.3.0, without signal-polyfill and the modulepreload polyfill 88.0 KB 244.0 KB
Gyral 0.3.1-next.1, client-only 86.1 KB 242.2 KB
Gyral 0.3.1-next.6, the two-call define and the error channel 86.9 KB 243.0 KB
Gyral 0.3.1 from npm (sabacc's main branch today) 87.4 KB 244.6 KB

Against Lit on the same bundler, Gyral 0.3.1 costs 4.7 KB more on the classic page (+5.7%) and 6.5 KB more on the 3D page (+2.7%).

The first report said "+5.9 and +7.2 KB, mostly Effect". Both halves need correcting. That baseline was a Vite 7 build, and the Gyral builds used Vite 8, which by itself made the Lit pages 1.2–1.7 KB smaller. On the same bundler, 0.3.0 cost 7.7 and 8.4 KB more: worse than first reported. And Effect explains why the pages are big, not why they grew. Effect is 51.3 KB of the classic page, about 60%, and it is byte-for-byte the same in every build. Three.js is 146.6 KB, about 60% of the 3D page.

The prereleases took the gap down to 3.4 and 4.1 KB, and the release gave part of that back. On the classic page, next.6 added 0.8 KB, almost all of it Gyral's error channel, and the release added another 0.5 KB, as sabacc moved its long-running watches into subscriptions and the room's countdown onto every. The 3D page grew 1.2 KB more than that, in the 3D view's own code: a responsive layout for the HUD and the name plates.

Where the classic page's bytes go, comparing the last Lit build with sabacc's main branch on Gyral 0.3.1:

Part Lit Gyral Change
Effect, the rules engine 51.3 51.3 0
The app's own code 17.1 19.1 +2.0
The app's stylesheet 2.2 2.2 0
Template text 2.2 1.7 −0.5
The framework: Lit and @lit-labs/signals, or Gyral's core, http and time 6.8 12.6 +5.8
signal-polyfill 2.2 — −2.2
Vite's modulepreload polyfill 0.3 — −0.3

The numbers are KB gzipped. To attribute them, I rebuilt each commit with source maps (the output is identical once the sourceMappingURL comment is removed) and assigned every byte of the shipped files to the source file it maps to. Template text is the static markup of the app's templates: the literal strings of Lit's html and svg templates, and the markup strings of Gyral's compiled templates. Each figure is what deleting exactly that part's bytes saves after gzip. Gzip compresses across parts, so the figures overlap a little: the changes add up to 4.8 KB against the measured 4.7.

Gyral's renderer, element base, props and scheduler come to 5.8 KB, about the same as lit-html, ReactiveElement and LitElement together (6.0 KB). Gyral's component host, which holds the state and runs the update loop, is 0.9 KB, against 0.7 KB for @lit-labs/signals. The rest is the Model-View-Intent machinery sabacc chose to use:

That keyed list is a single each() over at most a hand of card buttons. When they measured it, rendering those buttons with .map() saved 1.3 KB, but rows are then matched by position, so after a lock or a trade focus can land on a different card. They kept each().

The app's own code grew by 2.0 KB. Effects as data means a driver for each browser effect, so sabacc now has drivers for the page address, navigation, the clipboard and reduced motion (its countdown clock was a driver too, until every replaced it). On the 3D page, the name plates and pot labels became two small components.

Two savings in the table aren't Gyral's. Leaving Lit let them drop signal-polyfill, which @lit-labs/signals needed, for the 30-line cell: 2.2 KB. A Lit app that never used signals wouldn't have carried it. Turning off Vite's modulepreload polyfill saves 0.3 KB in any Vite app, Lit included. Of the 4.3 KB the classic page lost between 0.3.0 and 0.3.1-next.1, those two account for about 2.4 KB. Gyral 0.3.1 accounts for about 1.8 KB, through short error codes in production, features bundled only when used, client-only builds and the scan fix.

Sabacc's owner accepted the size: there is no size budget in CI, and the engine stays on Effect. Any large saving on these pages would come from the engine, not the interface.

Other costs

What it means for other Lit apps

If your Lit app is client-only and small, don't expect Gyral to make it smaller. The renderer and element are about Lit's size, and the intents, commands and drivers are extra. What you get for those bytes is the architecture: views that only name intents, effects you can test as data, and template mistakes caught before the code runs.

If you do move, sabacc's route is a good one:

Try it

Play at sabacc.starwars.run. The source is at github.com/mikezupper/sabacc. Gyral's side of this story is in the core changelog.

Thanks to the sabacc team for a precise report, for letting me quote their code, and for reviewing a draft of this post. Several of 0.3.1's changes started as a line in their report.