Jonathan Mellette Contact

Solutions architecture · Commercial title · Demo live · Launches Nov 10, 2026

FIRST LIGHT

A commercial, story-driven brick-breaker for Steam: 132 hand-built boards, each machine-checked winnable, five narrators over a 63-track score (33 of them on the soundtrack album), shipped through a gated release pipeline that has put 130 builds live.

Status
Demo live on Steam since 18 Aug 2026 · Steam Next Fest, 19–26 Oct · full game and the 33-track soundtrack album on 10 Nov 2026
Version
v0.174.3, build 235, live since 21 Sep 2026 (pre-1.0)
Licence
Proprietary · Ephphatha Games. Third-party notices ship with the game (SIL OFL fonts; MIT-licensed Electron, PixiJS and a modified steamworks.js)
Runs on
Windows 10 64-bit · macOS 12 on Apple Silicon · Steam Deck targeted, with its own input layout (not yet rated by Valve)
Stack
TypeScript 5.9 strict · PixiJS 8 · Electron 43 · steamworks.js (forked native module) · Vitest · Playwright
Links
Steam store · playfirstlight.com · Press kit · Trailer · source is private

Measured · how

By hand

Play all 132 boards to the end after every change.

Now

A reachability prover inside the test suite: 132 of 132 pass, 39 of them with indestructible gold in play.

At a glance

boards proven winnable by the reachability prover
132/132
tests in 24 files, all passing; strict typecheck clean
327
builds set live on Steam (84 → 235)
130
lines of tooling vs 32,897 of game runtime (25,046 vs 16,801 code‑only)
41,358
How these were measured
  • 132/132 — the prover in tests/solve.ts run over every board in an isolated scratch copy of the repository, 12 Oct 2026: 132 pass, 0 bricks sealed; 39 boards contain indestructible gold.
  • 327 — vitest run in the same scratch copy: 24 test files, 327 tests passed. tsc --noEmit exited clean for both the game and the workshop configs.
  • 130 — counted from the liveness record the release scripts write: 130 builds, numbered 84 to 235. Every build from 114 on was recorded by script; the 23 before it were entered from the earlier hand-kept release list.
  • 41,358 — git ls-files over the scripts and the workshop, counted with wc -l; game runtime is src plus the Electron shell. Code-only drops blank and comment-only lines.
FIRST LIGHT, Memory 44: a maple leaf built from red, orange and gold bricks, whole at the start of the round, with the memory and song titles in the footer
Fig. 1Memory 44, “Fall Dance”: a maple-leaf board, whole at the start of the round, with the memory and song titles in the footer. Press-kit capture, cropped to the play field on small screens.

The short version

  1. An unfinishable board is a failing test. A reachability prover peels every board from its open edges, and any breakable brick left standing fails the suite.
  2. The game upload reads the defect tracker first, scoped to what it ships. An unknown state counts as blocked.
  3. The desktop shell moved from Tauri to Electron in one day, on a written spike that listed what it did not prove; a notarized build shipped that night.

Case 1 of 4

A level you can’t finish is a bug you ship to everyone

A prover in the test suite checks that every breakable brick on every board can be reached. All 132 boards pass.

In a brick-breaker, indestructible gold can wall a breakable brick off where no ball will ever reach it. The player gets no crash and no error, just a level that never ends. FIRST LIGHT’s boards are painted in a spreadsheet and edited all the time, so playing all 132 to the end after every change was never going to happen.

So I built a prover. It pads the 13-column board with an open border, flood-fills every empty cell a ball could reach, removes each breakable brick touching that space, and repeats until nothing changes: a peel to a fixpoint. Any breakable brick still standing is sealed, and the test fails. Because the model is ball-only, it also shows that no board needs a power-up to finish, and a second test asserts exactly that.

It is a reachability proof on the grid, not a physics simulation, aimed at the one failure that makes a board unfinishable, and the board file’s own header names it as the contract every board must meet. The build runs it with the rest of the suite every time, though a red suite doesn’t yet stop the build (Case 2).

The peel on two small invented boards Left board: the gold ring has one gap, so the peel reaches every breakable brick by pass 3 and the board passes. Right board: the gold ring is closed, two breakable bricks stay sealed, and the board fails. Passes peeled by pass 3 Fails 2 bricks sealed 1 1 1 1 1 3 2 3 1 1 1 1 1 1 1 1 × × 1 1 1
Fig. 2Two invented 5-column boards (real ones are 13 wide). Gold squares never break, and each number is the pass on which the peel, starting from the dashed open border, reaches that brick. Left, one gap lets the peel in. Right, a closed ring seals two bricks, and the test fails.
How we know

The prover is isWinnable() in tests/solve.ts: a four-neighbour flood fill and peel, capped at 500 passes. Run over all 132 boards in an isolated scratch copy on 12 Oct 2026, it found 132 winnable and 0 sealed bricks; 39 boards contain gold. The two tests that call it, one for winnability and one for needing no power-up, are in tests/levels.test.ts, and both passed in the 327-test run that day. The figure’s two boards are illustrations, checked with the same peel rule.

Case 2 of 4

Release gates that stop, and know what they’re gating

Each gate stops the run or demands a person’s explicit override (the test step, for now, only reports), and the upload gate checks only the shipment it is guarding.

Debug keys exist only under the dev server: they are derived from the build command, so every distributable is clean by construction. Two checks back that up anyway. A fail-closed one confirms the config still derives the keys that way; the other searches the built bundles’ bytes for a sentinel string before anything is notarized or uploaded.

The Steam upload calls a ship gate that reads the defect tracker before anything is staged. It is scoped per shipment: the game-and-demo upload asks only about game, demo, build and Steam blockers, so a soundtrack bug can’t hold up the game. Open, in-progress and fixed-but-unverified blockers all stop the upload, and an unknown state (an unreadable tracker, an unknown scope) counts as blocked. Its categories come from the tracker’s own legend, so a new one can’t quietly fail open. Shipping over a blocker takes a person typing an explicit override at a terminal; an unattended run aborts.

Static checks can’t see a runtime death. One build passed typecheck, tests, receipts and notarization and still could not start, so the build now launches the packaged macOS app and fails if the front end never reports its version.

Liveness is a handshake too: a build is recorded as live only if its log shows it was sent to Steam, and a doc checker fails when any document disagrees with that record.

FIRST LIGHT’s release path The build script runs two strict typechecks that stop the run, then the test suite, which reports but does not yet stop the build, then the first debug-key guard. Bundling and packaging follow, and the Mac apps are signed during packaging. Then come the second debug-key guard, the receipt and symbol check, and the macOS boot check, all of which stop the run; only after the boot check are the Mac apps notarized and stapled. The upload script, run separately, starts with the ship gate and then uploads to Steam. After a person sets the build live in Steamworks, a second run records it as live only if the log shows it was sent. A Playwright boot probe and a doc checker run only on demand. BUILD SCRIPT Typecheck: game + workshop strict; a failure stops the run Test suite · 327 tests reports; does not yet stop the build Debug-key guard 1 of 2 keys must derive from the dev server bundle + package Mac apps signed here Debug-key guard 2 of 2 sentinel search of the built bytes Receipts + symbol check refuses a bundle without the fork macOS boot check packaged app must report its version notarize + staple Mac apps only UPLOAD SCRIPT · RUN SEPARATELY Ship gate blockers in this shipment’s scope unknown = blocked; override is typed upload to Steam set live by hand in Steamworks Recorded as live separate run; refuses unless the log shows it was sent ON DEMAND · NOT WIRED IN Playwright boot probe dev server, about two seconds Doc checker liveness claims vs the record
  • Stops the run when it fails
  • Reports only
  • Runs on demand
Fig. 3The release path, top to bottom. Licence, notice and content checks also run in the build script and are left out here. Only the first launch of the Windows build is still checked by hand.
How we know

Read on 12 Oct 2026: scripts/build_electron.sh (both typecheck gates, both debug-key guards, the receipt step and the boot step, each of which aborts the script, with notarization after the boot step) and electron-builder.config.cjs (the signing identity, applied while the Mac apps are packed); scripts/ship_gate.mjs and scripts/lighttosteam.sh (the scoped gate; its only caller, which always asks about game and demo; the typed override; the abort when no terminal is attached; the separate run that records a build as live), and scripts/steam_live.mjs with scripts/check_docs.mjs (the liveness record and the doc check). The test step pipes vitest through tail under sh with set -e and no pipefail, so tail’s exit code wins; reproduced with a /bin/sh pipeline the same day. The Playwright probe and the doc checker are not called by any script.

Case 3 of 4

Swapping the desktop shell mid-project, on evidence

Tauri to Electron in one day: a written spike, a notarized build that night, and a rollback kept until the new shell had earned it.

The game first shipped on Tauri, Rust plus the system webview, and the webview kept costing time: a cursor workaround because pointer lock didn’t work, a JavaScript-to-Rust handshake just to size the macOS viewport, a WebView2 dependency on Windows, two system renderers that could change under a shipped build, and no route to the Steam Deck, because the Windows webview doesn’t run under Proton.

I ran a branch-only spike. It showed the game running unmodified in Electron, and it listed in writing what it did not prove: achievements, saves, notarization and the Steam overlay. When the old shell later left the ship path, one fail-closed check stayed on purpose: the stager refuses to upload any bundle it can’t positively identify.

  1. Build 84, the last Tauri build, goes live on Steam.
  2. Branch-only Electron spike, with a written list of what it did not prove.
  3. Notarized Developer ID build.
  4. Build 91, the first Electron build, goes live.
  5. The Tauri rollback is retired.
  6. The old shell leaves the ship path: 193 lines out, 92 in, across three scripts.
How we know

Commit timestamps from git log, read 12 Oct 2026: four commits on 12 Aug (build 84 live, the spike, the notarized build, build 91 live), the rollback’s retirement on 13 Aug, and git show --stat for the 1 Sep cleanup (3 files, 92 insertions, 193 deletions). The process split is in electron/main.cjs; the fork, its restore step and its check are in scripts/vendor_steamworks.mjs and scripts/verify_electron_build.mjs. On 12 Oct, a byte search found the overlay callback’s symbol in both vendored binaries and in neither of two upstream builds, and GitHub’s public API listed the upstream pull request as open and unmerged, with nine lines added. The framework share is du of the build-235 Mac app the same day: Electron’s frameworks take 281,196 of its 636,312 KiB (44%). The storage requirement and platforms are from Steam’s public store data, read the same day.

Case 4 of 4

The tooling is bigger than the game

By line count the tools outweigh the game, with or without comments, and most workshop modules import the game’s live source, so they can’t fall out of step.

The game core imports no renderer and its random numbers are seeded, so bots can play it. The balance sweep plays each of the 132 rounds 100 times independently, with four selectable player models: teleport, follow, a “human” with reaction time and flick error, and aim. It logs 36 fields per run, so a new question becomes a query instead of a fresh simulation. A campaign bot plays rounds 1 through 132 as one player, with lives, score and power-ups carried forward, across ten seeds by default.

A workshop of 37 active tool pages (45 linked, 8 retired) runs on a second dev server. 32 of its 43 modules import the game’s live source rather than snapshots, so those tools can’t fall out of step with the game, and the workshop is typechecked in the build gate. Level art round-trips through Excel, with a lossless round-trip check added after a palette collision silently changed a board.

The voice pipeline is tooled the same way. Its model clips the first or last word of about 40% of renders, so every line is rendered with padding at both ends, the pads are found by paired silences, and the masters are kept: a bad cut means re-trimming, never re-rendering an approved take.

Tracked first-party lines in FIRST LIGHT, 12 Oct 2026.
Tooling, all lines41,358
Game runtime, all lines32,897
Tooling, code only25,046
Game runtime, code only16,801
How we know

Line counts from git ls-files and wc -l on 12 Oct 2026. Tooling is scripts/ (22,208 lines in 152 files) plus the workshop (19,150 in 94); runtime is src/ plus the Electron shell (40 files), including a 3,529-line board data file and a 2,016-line narration script. Code-only drops blank and comment-only lines. The sweep’s defaults and its 36-field run record are in scripts/balance_sweep.ts, the player models in scripts/keeper_bot.ts, and the campaign bot’s ten seeds in scripts/campaign_bot.ts. The workshop index links 45 pages, 8 of them marked retired; 32 of its 43 TypeScript modules import from src/.

Press-kit captures of the current, pre-release build

FIRST LIGHT title screen: an arcade cabinet in a garage at sunrise, with Story Mode in four difficulties, Load Game and Afterglow
Fig. 4The title screen: an arcade cabinet in a garage at sunrise.