CRACKPOINT is a number puzzle roguelike. The Prologue, a free, feature-complete vertical slice, runs on the web as a progressive web app. A lighter variant, Quick Crack, ships as an instant game on Facebook, YouTube Playables, and Reddit via Devvit. A Python FastAPI backend handles puzzle generation and validation for the online mode. A TypeScript engine mirrors the same logic for offline play.
The problem was never “can we build it?” It was “can we ship it to four platforms without maintaining four codebases?”
Three Interfaces, Independently Composed
The platforms differ in three orthogonal ways: how game logic executes, how data persists, and how the platform lifecycle works. Each of those differences is isolated behind its own interface. They compose independently; you pick one from each column, and the combination defines a build target.
GameAdapter abstracts where the game logic runs. ServerAdapter makes HTTP calls to the FastAPI backend for online play. ClientAdapter runs the TypeScript engine locally for offline and instant game modes. QuickCrackAdapter exposes a simplified single-puzzle interface over the same engine.
StorageAdapter abstracts persistence. Four methods (get, set, delete, dispose) and four implementations: LocalStorageAdapter for the browser, FBStorageAdapter for Facebook’s player data API, YouTubeStorageAdapter for YouTube’s game data API, and RedditStorageAdapter for Devvit’s Redis bridge.
InstantGamesSDK abstracts platform lifecycle for instant game builds. Six methods (initialise, startGame, submitScore, share, onPause, onError) covering the full lifecycle from boot to score submission. Facebook, YouTube, and Reddit each have their own SDK implementation. The web version is a no-op wrapper with a navigator.share fallback. Prologue has its own app shell and lifecycle; the SDK layer doesn’t apply to it. Instant game builds compose all three interfaces. Prologue composes two.
Adding a new platform means implementing StorageAdapter and InstantGamesSDK, then adding a detection case to the platform factory. No game logic changes. No adapter rewiring. The factory selects the right combination at build time via Vite environment variables:
// Build target composition via Vite env vars
// Each combination produces a distinct deployment bundle
// prologue (web, online) → ServerAdapter + LocalStorage
// instant-web → ClientAdapter + LocalStorage + WebSDK
// instant-facebook → ClientAdapter + FBStorage + FacebookSDK
// instant-youtube → ClientAdapter + YouTubeStorage + YouTubeSDK
// devvit-quickcrack → ClientAdapter + RedditStorage + RedditSDK
The backend knows nothing about any of this. One API, one set of endpoints. No user-agent sniffing, no platform headers, no conditional logic. Quick Crack and the instant clients don’t talk to the API at all; they run the TypeScript engine locally.
The Hard Platform: Reddit Devvit
Every other platform’s storage is a direct API call. You call a function, you get a promise, it resolves with your data. Devvit broke that assumption in two ways.
First, there’s no direct storage access. The game runs in a WebView iframe, but persistence lives in Redis on the host side, inside the Devvit app running in the Reddit post. The WebView can’t touch Redis. Communication happens via postMessage, which means building a full request-response correlation protocol:
// WebView sends a storage request with a unique ID
const requestId = `req_${Date.now()}_${this.sequence++}`;
this.pending.set(requestId, { resolve, reject });
window.parent.postMessage({
type: 'redis_get',
key: `checkpoint:${runId}`,
requestId
}, '*');
// Host responds, adapter matches by requestId
// 5-second timeout per request catches dropped messages
Each request gets a unique requestId. The adapter tracks pending promises in a Map. A five-second timeout catches messages that never come back. This is the RedditStorageAdapter: same four-method interface as every other adapter, but the implementation is an asynchronous message bridge rather than a direct API call.
Second, Devvit has a host runtime. Every other platform is purely client-side. On Reddit, the host app manages the Redis connection, handles leaderboard writes via sorted sets, and whitelists which key patterns the WebView is allowed to read and write. This required extracting a shared message handler into @thepromisedclan/devvit-shared, used by both the Quick Crack and Prologue Devvit apps. The handler routes incoming messages to Redis operations based on type and enforces key pattern restrictions.
Bundle Size as a Platform Constraint
Facebook Instant Games and YouTube Playables impose bundle size limits on uploaded ZIP archives. Platform abstraction solves the API differences, but if the instant client ships with Prologue’s server adapter, terminal loading sequence, and leaderboard screens in the bundle, it won’t fit.
The instant client (clients/instant/) is a separate Vite entry point. It imports from @crackpoint/shared but only reaches the engine, evaluator, hint pool, theme system, and the UI components needed for a single-puzzle flow. Vite tree-shakes everything else. ServerAdapter, the checkpoint system, narrative content: none of it makes it into the instant bundle because none of it is imported.
The dependency relationship is one-directional. Prologue imports the instant client’s InstantApp as a workspace dependency for its Quick Crack secondary mode. The reverse doesn’t apply; the instant client has no awareness of Prologue. This means changes to Prologue-only features have zero impact on the instant bundle. The platform-constrained builds stay small by construction, not by vigilance.
Keeping Two Engines Honest
The TypeScript engine exists so Quick Crack and instant game platforms can run puzzles without an API dependency. But “port the engine” means maintaining two implementations of the same algorithms (evaluator, solver, hint pool, rarity scoring) in different languages. If they diverge, players get different puzzles depending on which platform they’re on.
Python is the source of truth. A generation script runs the Python engine with a fixed seed and writes outputs to a golden data file. The TypeScript parity test suite loads that file and asserts the TypeScript engine produces identical results for the same inputs. Current coverage: fifty evaluation cases, fifty remaining-solution counts with progressive hint application, and ten hint pool rarity distributions.
The testing goes deeper than cross-language parity. The Python engine has two solvers: brute-force exhaustive enumeration and constraint propagation with pruned search. An internal equivalence test generates thirty random puzzles per run and asserts both solvers return identical solution sets. The TypeScript engine uses constraint propagation only. Its correctness is validated transitively: TypeScript matches Python’s constraint solver via the golden data, Python’s constraint solver matches brute-force via the equivalence test.
The intrusion probability sigmoid is implemented in both engines. Rather than golden fixtures, the Python tests hardcode expected values from the TypeScript implementation and assert they match within a tolerance of ±0.002 across five room numbers. This parity test was added after a real divergence: the two sigmoid implementations used slightly different constant precision, producing values that drifted at high room numbers. The fix was trivial (align the constants), but without the test it would have silently produced different intrusion rates per platform.
The evaluator golden tests caught an early bug in the TypeScript port: a misplaced-digit double-counting error. The Python evaluator correctly handles duplicate digits: guess [1,1,2] against solution [1,2,2] produces one placed and one misplaced, not two misplaced. The initial TypeScript port got this wrong. The golden test caught it before it reached any platform.
One deliberate divergence: the PRNGs are different. Python uses Mersenne Twister, TypeScript uses mulberry32. They produce different sequences for the same seed, which means the engines don’t generate the same room for the same seed. This is intentional; room generation only needs to be deterministic within each engine. The golden tests validate the logic layer (evaluation, solving, rarity scoring), not the generation layer.
What I’d Do Differently
Three decisions I’d revise with hindsight.
The ClientAdapter / ServerAdapter split happened late. The original architecture was server-only. When Quick Crack needed client-side puzzle generation, the TypeScript engine was ported from the Python implementation and ClientAdapter was added alongside the existing ServerAdapter. Because it wasn’t planned from the start, checkpoint restoration has awkward asymmetries between the two implementations: the server returns a full room snapshot, while the client re-derives the room from a stored seed and overlays bookkeeping state (used reserves, attempts, revealed hints). If I’d designed for offline-first from day one, the interface would have a single restoration contract.
The Devvit message protocol should have been typed from the start. DevvitMessage is { type: string; [key: string]: unknown }, effectively stringly typed. A discriminated union (type: 'redis_get'; key: string | type: 'redis_set'; key: string; value: string | …) would have caught protocol mismatches at compile time instead of at runtime, where a typo in a message type means a five-second timeout and a silently dropped request.
QuickCrackAdapter is a separate interface from GameAdapter. It exposes generatePuzzle(), submitGuess(), and drawReserveHint(), a simplified subset for single-puzzle play. The Prologue’s GameAdapter exposes createRun(), restoreRun(), completeRoom(), usePowerUp(), and checkpoint lifecycle. The room progression system, including time pressure, zone escalation, and difficulty scaling, is covered in Difficulty as Data. They should have been the same interface, with Quick Crack as a degenerate case: a one-room run with no lives and no checkpoint. Instead, there are two parallel adapter hierarchies, which means Quick Crack can’t gain features like leaderboard submission without duplicating the plumbing that already exists in GameAdapter.
None of these are fatal; the game ships and works across all four platforms. But they’re the kind of decisions that compound as the product grows, and they’re worth naming because “what would you do differently” is a more useful question than “what did you get right.”
Outcome
CRACKPOINT Prologue deploys to Render (FastAPI) and Cloudflare Pages (React PWA). The Devvit apps are published to Reddit. Facebook and YouTube builds produce ZIP bundles for platform submission. A single GitHub Actions pipeline handles all targets: push to production, version bump, test, build per platform, deploy.
Four platforms. Three abstraction layers. Two engine implementations. One codebase. No special cases.