Shipping the pipe before there is anything to put in it
What was true before
There was a repository, a website, and no way for anyone to run anything.
That is a normal place to be a few weeks into a project, and the normal next move is to build the thing — get a game loop going, get rooms and verbs and a parser, and worry later about how a stranger ends up with it on their machine. Distribution is a packaging problem. Packaging problems are solved.
The trouble with that plan is that it is only true in the abstract. Distribution is not one problem, it is a chain: a compile step, a release trigger, a hosted artifact, a package manager, a formula, a token with write access to a second repository, and a version number that has to agree with itself at four separate points along the way. Every link in that chain is easy. The chain is not. And each link you defer is a link you will end up debugging later, while also debugging whatever the game is doing wrong, with a build you cannot reproduce because it has never run green.
So m-002 builds the whole chain first, with nothing at the end of it but a lantern.
The decision, and the option that lost
Two decisions, one nested in the other.
The outer one: distribution before content. The milestone’s objective states it plainly — prove the whole path from commit to a stranger’s machine while it is still cheap to change. The option that lost was building the game first. It loses on retrofit cost. A game that assumes a Node runtime is a game you cannot ship as a binary without unpicking it, and by the time you find that out you have a game to unpick.
The inner one is the interesting one, because it has code behind it.
The terminal UI is written in Ink — React, rendering to a terminal instead of a DOM. That is a genuinely good way to write a reactive TUI: state, effects, components, flexbox layout, all the machinery already in your head. It is also, straightforwardly, a Node program. React is a Node dependency. Ink is a Node dependency. The obvious way to ship a Node program is:
npm install -g takelantern
That was the option that lost, and it lost on one line of reasoning: it puts a version of Node between a stranger and the game. Not a hard requirement — a stated one. “Requires Node 20 or later” is a sentence that costs you some fraction of the people who read it, and you never find out which fraction, because they do not file an issue saying they did not install Node. For a project whose entire premise is that a Spectator might become a Player on impulse, a prerequisite is a leak in the only funnel there is.
The choice, then: write it in React, ship it as a native binary with no Node inside it and no Node
required outside it. Two builds stacked on each other — esbuild flattens the app and every
dependency into one JavaScript file, then bun build --compile welds that file to a JavaScript
runtime and emits an executable.
The cost of that choice is the whole rest of this article.
The code that shipped
The app is small. Ninety-one lines across three files, which is the point — everything hard here is in the shipping, not in the writing.
The opening reveals a line at a time, and the reveal is a useEffect that schedules the next line
and cleans up after itself, because Ink is React and this is what React does:
apps/cli/src/app.tsx
useEffect(() => {
if (visibleLines >= OPENING_LINES.length) return;
const delay = OPENING_LINES[visibleLines] === "" ? 400 : 800;
const timer = setTimeout(() => {
setVisibleLines((n) => n + 1);
}, delay);
return () => clearTimeout(timer);
}, [visibleLines]);
A blank line waits 400ms and a line of prose waits 800ms, so the pauses fall where a reader’s eyes would fall anyway. That is the entire animation. There is no frame loop.
The bundle step is where the compromises live:
apps/cli/build.mjs
await build({
entryPoints: ["src/index.tsx"],
bundle: true,
platform: "node",
format: "esm",
target: "node22",
outfile: "dist/takelantern.mjs",
define: {
"process.env.NODE_ENV": '"production"',
TAKELANTERN_VERSION: JSON.stringify(pkg.version),
},
alias: {
"react-devtools-core": "./src/shims/empty.js",
"yoga-wasm-web": "yoga-wasm-web/asm",
},
});
Those two aliases are each a small defeat, and they are covered below.
The define is worth a second look, because it is how the version number stops being a runtime
question. esbuild substitutes the literal at bundle time, and the source falls back to reading
package.json when the constant is absent — which is only ever true under pnpm dev:
apps/cli/src/version.ts
declare const TAKELANTERN_VERSION: string | undefined;
// Inlined by esbuild at bundle time; falls back to package.json in dev.
export const version: string =
typeof TAKELANTERN_VERSION !== "undefined"
? TAKELANTERN_VERSION
: (createRequire(import.meta.url)("../package.json") as { version: string })
.version;
A compiled binary has no package.json next to it to read. Either the version is baked in at
build time or the binary cannot tell you what it is.
At the far end of the chain, the release workflow writes the Homebrew formula as a heredoc and pushes it to a second repository. Three binaries are downloaded back out of the release purely to be hashed:
.github/workflows/build-cli.yml
- name: Compile
working-directory: apps/cli
run: bun build dist/takelantern.mjs --compile --target=${{ matrix.target }} --outfile dist/${{ matrix.artifact }}
- name: Upload release asset
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${{ github.event.release.tag_name }}" "apps/cli/dist/${{ matrix.artifact }}" --clobber
and then, in a job that waits on all three:
on_macos do
if Hardware::CPU.arm?
url ".../${TAG}/takelantern-darwin-arm64"
sha256 "${SHA_DARWIN_ARM64}"
else
url ".../${TAG}/takelantern-darwin-x64"
sha256 "${SHA_DARWIN_X64}"
end
end
Generating Ruby from a shell heredoc inside YAML is three languages deep and there is no syntax checking at any layer. It works. It is also the part of this milestone most likely to be replaced.
What it cost
Ink’s layout engine could not come along. Ink lays out with Yoga, Facebook’s flexbox
implementation, which ships to Node as WebAssembly. A single-file executable has nowhere to put a
.wasm sidecar and no reliable way to find it if it did. The fix is the second alias:
yoga-wasm-web becomes yoga-wasm-web/asm, the same layout engine compiled to plain JavaScript
instead. It is slower. At the size of this UI that is unmeasurable, and if it ever becomes
measurable the answer is a different renderer, not a different bundler.
React DevTools tried to come along. react-devtools-core is reachable from Ink’s import graph
and would otherwise be bundled into a shipped binary. It is aliased to a file whose entire
contents are export default undefined;.
That one-line file then broke the build gate. dependency-cruiser has a rule against orphan modules — files nothing imports and which import nothing — because an orphan is usually something somebody forgot to delete. The shim is an orphan by construction: esbuild reaches it through an alias, so no source file names it. The gate was right about the facts and wrong about the conclusion, and the fix was to widen the exemption:
.dependency-cruiser.js
- "…|astro[.]config|src/index[.]ts$"
+ "…|astro[.]config|src/index[.]tsx?$|shims/"
Two things in that one line. The shim directory is exempt, and index.ts became index.tsx?
because the entry point had grown an x when it started rendering React and the existing
exemption had silently stopped matching it.
Ninety-one lines of source compile to ninety-six megabytes. The bundle is 974,971 bytes — call it a megabyte of JavaScript, most of it React and Ink. The binary built from it is 100,200,274 bytes. The difference is an entire JavaScript runtime, stapled on, once per platform, per release. Three platforms means every tagged version publishes roughly 300 MB of release assets so that nobody has to install Node. That is the trade, stated in bytes.
Two acceptance criteria went unchecked, and one still is. TASK-010 claimed the binary runs without Node and prints the opening; both boxes stayed empty, because CI compiled the binary but nothing ever ran one. Writing this article was the first time anyone did. On Linux x86_64, with the environment stripped to nothing at all:
$ env -i ./dist/takelantern --version
0.2.0
No PATH, no NODE_ENV, no Node. It prints the version, prints the help, renders the opening,
and exits 0. Those two boxes can be ticked.
The third cannot. brew install takelantern/tap/takelantern on a clean macOS machine is still
unverified, and macOS is the platform most of the audience is on. The formula is generated, the
assets are attached, the SHAs are computed from the artifacts themselves — every link looks right
and the chain has not been pulled.
Two of the milestone’s six tasks left no trace in this repository. The tap lives at
takelantern/homebrew-tap, so the tasks that created and wired up the formula have no commit here
carrying their title. The pipeline that generated this episode finds a milestone’s work by
matching task titles against commit subjects; it matched four of six and said so. A milestone that
spans two repositories is invisible to a tool that reads one, which is a note against the tooling
rather than against the milestone.
What it makes possible
The next thing that gets built gets shipped by merging it. The compile step, the release trigger, the asset upload, the formula rewrite and the tap push all already run without anyone typing anything, so the cost of putting a change in front of a person is now the cost of the change.
That is the whole return on doing this first. Everything after it is a game.