From Zero to Launch: What We Built
From Zero to Launch: What We Built
The first post on this blog ended with a promise: Classic Sudoku is coming, development will be documented honestly, and if you're here for the journey, pull up a chair.
This post is the other bookend. Between that first post and today, PuzzleProwl went from a domain with a coming soon page to a fully functional puzzle platform. This is an account of everything built, every decision made, and a few things that surprised me along the way.
The Game Engine
Classic Sudoku required building four things that don't overlap: a puzzle generator, a solver, a UI layer, and a game state manager. Keeping them separate was a deliberate architectural decision that paid off every time a bug needed isolating.
The generator produces valid, uniquely-solvable puzzles across five difficulty levels. Beginner through Hard use rotational symmetry for clue placement. Expert goes further: it removes clues individually, shuffles the remaining ones, and runs multiple passes until no more can be removed without creating an ambiguous puzzle. The result is puzzles with 17 to 21 clues, which is the practical lower bound for a uniquely-solvable Classic Sudoku.
The solver is constraint-based, working through naked singles, hidden singles, naked pairs, and pointing pairs before falling back to backtracking for puzzles that require it. The solver has two jobs: verifying that a generated puzzle has exactly one solution, and powering the hint system by finding the next available move during gameplay.
The UI layer handles everything that touches the DOM: rendering the grid, processing input from both keyboard and touch, updating the display. It receives state and renders state. It has no opinion about game logic.
The game state manager is the entry point that wires everything together, manages the state object, and fires events that other modules listen to. No direct DOM manipulation lives here.
Generation runs in Web Workers so the main thread never blocks while a puzzle is being produced. Multiple workers run in parallel, one per CPU core up to eight, and the first valid result wins for most difficulties. For Expert, all workers run to completion and the puzzle with the fewest clues wins.
The Hint System
The hint system is the most architecturally interesting thing on PuzzleProwl, because it sits at the intersection of UX, game design, and monetization simultaneously.
Four levels, increasing in specificity and cost:
A Region Highlight shows the row, column, or box where a move is available. It uses a "most-filled wins" heuristic to pick the tightest constraint, which gives the player the most useful signal with the least information revealed.
A Cell Highlight identifies the specific cell that can be solved next. The player still has to figure out which digit goes there.
A Technique Hint names the solving technique and highlights the relevant cells. Naked Single, Hidden Single, Naked Pair, Pointing Pair, or Advanced Technique for anything that requires backtracking. The player knows what to look for without being given the answer.
A Solution Step fills in one cell and explains why in plain language. This is the full hint. It costs the most tokens because it delivers the most value, but it's deliberately designed to still teach something rather than just advance the board.
The hard rule: hint balance lives in MariaDB and is validated server-side on every use. Client-side balance is display-only. A player who knows how to open developer tools cannot grant themselves free hints.
Token costs are 1, 2, 4, and 8 respectively. Every signed-in player gets one free Level 2 hint per day. Anonymous players get one free Level 1 hint per day, tracked in localStorage. Not cheat-proof by design: the cost of enforcing anonymous hint limits server-side without an account would be fingerprinting, which is worse than letting the occasional anonymous player get an extra free hint.
The Back-End
The API is a Node.js/Express server running on a Linode VPS, talking to a MariaDB database. Four environments, each with its own database: dev, alpha, beta, and production. Schema changes promote through the same path as code: never deploy code before its required migrations are in place, never in reverse.
Fourteen migrations cover the full schema from initial user tables through token balances, hint transactions, subscriptions, puzzle library, per-user progress saves for both daily and library puzzles, and the Stripe event idempotency ledger.
The API uses Firebase Admin to verify JWTs on every authenticated request. The verified uid from the token is the only trusted user identifier. A uid from the request body is never trusted, because any client can put anything in a request body.
Eight separate database users, two per environment, with distinct privilege levels: a runtime user with SELECT, INSERT, UPDATE, and DELETE, and a migration user with CREATE, ALTER, DROP, and INDEX on top of that. The runtime user cannot run migrations. The migration user is never used by the running application.
fail2ban runs eight jails covering rate limiting, authentication failures, 404 scanning, bad bot user agents, PHP probing, path traversal, shellshock, and recidivism. Progressive banning doubles the ban duration on each offense up to a four-week maximum.
Authentication
Firebase Auth handles OAuth identity. Google and Apple are live. Facebook is pending Meta's review process for the email permission scope, which has been submitted and is waiting in the queue.
Google and Apple use signInWithPopup as the primary method, with a redirect fallback for environments where popups are blocked. The redirect flow was originally the primary method and was demoted to fallback after discovering that Chrome 115 and later, Safari, and Brave all break redirect-based OAuth due to third-party storage partitioning. The popup flow is unaffected.
Apple has one notable requirement: name and email are only sent on the very first sign-in. If you miss them, they're gone. The auth handler stores them immediately on first login and never expects to receive them again.
The sign-in page supports a next parameter, so any auth-gated page can redirect to sign-in and return the player to exactly where they were after authentication resolves. The daily puzzle page, the library, and the profile page all use this pattern.
Account deletion is a multi-step flow: confirm intent, type DELETE, re-authenticate via the original OAuth provider, anonymize all MariaDB records via the API, then delete the Firebase Auth account. MariaDB records are anonymized rather than deleted because hard deletes break referential integrity. Stripe customers are cancelled and removed. The anonymization happens before Firebase deletion: if the order reversed, a failed Firebase deletion would leave an account half-deleted with no way to complete the process.
The Daily Puzzle Pipeline
Daily puzzles are pre-generated by a cron job running at 2am UTC, thirty days ahead on a rolling window. Difficulty rotates deterministically: a fixed epoch of January 1, 2026 anchors the rotation so the same date always maps to the same difficulty regardless of when the script runs. Easy, Medium, Hard, repeat.
On completion, the daily puzzle page generates a Wordle-style emoji grid: green squares for given clues, white squares for empty cells. The grid reflects the puzzle itself, not how any individual player solved it, so every player who completed the same puzzle shares the same grid. Time and hint count appear below the grid. Three share paths: X, Facebook, and clipboard.
The daily page uses the same Sudoku engine as the main game page. The engine was extended to accept a pre-generated puzzle object instead of generating one, controlled by a data attribute on the body element. The daily page waits for a sudoku:ready event before calling initGame, because deferred module scripts execute before DOMContentLoaded and the game engine needs to finish its own initialization first.
Auto-save for the daily puzzle uses POST to the API every 30 seconds, on visibility change when the tab hides, and on beforeunload using a keepalive fetch rather than sendBeacon. sendBeacon cannot set Authorization headers and the endpoint requires a Firebase JWT. A cached token in module scope makes the token available synchronously during beforeunload, since getToken is async and beforeunload has no patience for async operations.
The Puzzle Library
The library is a collection of pre-generated puzzles available to signed-in players on demand. Progress saves automatically with the same auto-save pattern as daily puzzles. The resume modal appears when a player returns to a puzzle they started: Continue restores the exact board state, pencil marks, and elapsed time. Start Fresh resets to a clean board.
Library puzzles are generated by a batch script that runs weekly at 3am UTC on Sundays. It fills each difficulty bucket to a configurable target, growing the library by a fixed increment each run and capping growth at a maximum to prevent runaway accumulation. At the current rate of 50 puzzles per bucket per week and a cap of 20,000, each bucket takes roughly seven to eight years to fill. Effectively unbounded for normal operations.
The library play page is served via an Apache RewriteRule:
RewriteRule ^/library/sudoku/([0-9]+)/?$
/library/sudoku/puzzle/index.html [L]
Note the target is the explicit file path, not the directory. Apache on Rocky Linux returns 403 when mod_dir handles a rewrite target that points to a directory rather than a file. This cost a debugging session to discover.
Stripe Integration
Token pack purchases and the Pro subscription both use Stripe Checkout, which means PuzzleProwl never handles raw card data. The hosted Checkout page is entirely Stripe's: PuzzleProwl creates a session server-side, redirects the player to Stripe, and waits for the webhook to confirm payment before crediting anything.
The success redirect carries a session_id but never triggers fulfillment. The success page polls the API every two seconds until the webhook confirms. If 120 seconds pass without confirmation, the page shows a support contact message with the session ID for reference.
Webhook signature verification happens before any database operation. The webhook endpoint uses express.raw() rather than express.json(), mounted before the JSON middleware in server.js, because the JSON parser consumes the request body and breaks signature verification. This is not an obscure edge case: it is the single most common Stripe webhook integration mistake and getting it wrong is silent, appearing only as a 400 on every webhook delivery.
Every processed webhook event ID goes into a stripe_events table with a UNIQUE key. Stripe retries webhooks on non-200 responses and will resend events from the dashboard. The idempotency ledger short-circuits duplicate processing on the unique key rather than relying on application logic to detect duplicates.
The Pro subscription credits 20 tokens to purchased_tokens on the invoice.payment_succeeded webhook, not on subscription creation. A subscription that fails to pay never gets tokens. The billing reason distinguishes first invoices from renewals: subscription_create for the initial payment, subscription_cycle for recurring ones.
Stripe's API version 2025-03-31 moved current_period_end from the subscription root onto subscription items. The webhook handler reads the root field first and falls back to the items array, which means it handles both shapes without requiring a version pin on the endpoint.
The PWA
The service worker uses a network-first strategy for all requests within the PuzzleProwl origin, with a cache fallback for network failures. API subdomains pass through without interception and are never cached. Firebase CDN, analytics, and third-party scripts pass through the same way.
The service worker pre-caches the home page, offline fallback, game pages, shared CSS and JS, icons, and the web manifest on install, using Promise.allSettled so individual asset failures don't fail the entire install.
The offline fallback page has no external dependencies. All CSS is inline, no fonts are loaded, and no scripts are required. An online event listener auto-redirects to the home page when connectivity returns.
The PWA has been tested on iOS Safari, iOS Chrome, BlueStacks, and desktop Chrome. iOS Safari and Chrome both work cleanly using the Add to Home Screen flow from the share sheet. An Android device test is pending.
What Surprised Me
A few things were harder than expected and worth documenting honestly.
The Apache directory rewrite limitation cost real time. The library puzzle page and daily puzzle page both use Apache rewrites to serve a single template for dynamic URLs. Rewriting to a directory path returns 403. The fix is to target the explicit file path in the RewriteRule. This is documented in passing in the Apache docs and not at all in most tutorials.
Stripe's current_period_end field location changed in a 2025 API version without particularly prominent documentation. Webhook events use the account's default API version, not the SDK's pinned version, which means the payload shape depends on when the account was created. A helper that reads both the root field and the items array handles this transparently.
beforeunload and async tokens is a genuine constraint. The browser gives beforeunload handlers almost no time to complete. Any async operation called during beforeunload will likely not finish. The solution is to cache the token synchronously during normal operation so it's available when beforeunload fires. The keepalive fetch option ensures the request completes even after the page is unloaded, but the token has to already be in scope.
The Stack, Summarized
For anyone who wants the quick version:
- Front-end: Vanilla HTML, CSS, JavaScript. No frameworks, no bundlers, no runtime dependencies.
- Site generator: 11ty with Nunjucks templates.
- Game engine: Generator, solver, UI, and state manager as separate modules. Web Workers for off-thread generation.
- Back-end: Node.js/Express on Linode. MariaDB for all persistent data.
- Auth: Firebase Auth. Google and Apple live. Facebook pending.
- Payments: Stripe Checkout. Token packs and Pro subscription.
- Infrastructure: Rocky Linux 10, Apache httpd, Let's Encrypt, fail2ban, systemd, rsync deployment scripts.
- Environments: Four: local WSL2 dev, alpha, beta, production. Code and schema changes promote through the same path.
What Comes Next
Killer Sudoku is next on the game roadmap. The engine architecture established for Classic Sudoku transfers directly: generator, solver, UI, and state manager as separate modules, with the same hint system and token mechanics. The new work is cage generation and the cage constraint solver.
Facebook OAuth resolves whenever Meta's review process completes. Ad integration is deferred until there is meaningful traffic to show advertisers: integrating an ad network before you have an audience is complexity with no return.
The daily puzzle has been running since the pipeline was built. Day one of public launch will not be day one of the puzzle sequence. That feels right. The puzzles should exist before the players arrive.
If you made it this far: thank you for reading. The games are live. Go play one.
--- Christian
PuzzleProwl.com