The previous Blupoli Devlog ended with publishing. The build had stopped behaving like a file copier and had become a boundary: source could exist without automatically becoming public product. Only a few hours later, the next boundary became obvious. Knowing what we publish is not enough once the product wants to remember what a player actually does inside it.

Blupoli Puzzles had grown for a long time as a catalogue of independent games connected by a common shell. That model works remarkably well while the main question is “what can I play?”. It starts to feel thin when the questions become “what was I playing yesterday?”, “which puzzle families do I spend time on?”, “how long is my current streak?”, “what is today’s challenge?”, or “can a future achievement system react to Sudoku, PolyPivot and a CPU game without learning the internals of every engine?”.

Those questions were not asking for another screen. They were asking for a product layer. That distinction shaped this phase of work: rebuild the Puzzles home page as a personalized lobby, turn My Progress into a real dashboard, and—more importantly—introduce a common game-result contract so every playable engine can describe completion, abandonment and useful metrics in the same platform language.

This entry follows directly from Publishing is not copying files. That story was about the boundary between repository state and public product state. This one is about the boundary between an individual game engine and the shared memory of Blupoli Puzzles.

The problem was never “store a score”

With only a few games, progress can look deceptively simple: plays, solved count and best time. Blupoli already had aggregate data shaped roughly like that. The complication appears when the full catalogue is considered. Some games have meaningful moves, some do not. Some expose hints or resets. Competitive games need win, loss and draw. A puzzle may have board size, difficulty and a stable seed. Another engine may produce useful domain-specific measurements that do not justify adding another universal top-level field. And one small-looking flag becomes important the moment daily play enters the product: was this a normal session or an official daily challenge?

The most dangerous shortcut would have been to let each engine solve persistence independently. It is fast at the beginning and expensive everywhere else. If one game writes its own IndexedDB history, another stores custom statistics in localStorage and a third emits a private event, every cross-game feature has to learn three dialects. The dashboard becomes an exception aggregator. Achievements couple themselves to individual engines. Remote synchronization would need per-game adapters. Every new puzzle raises the platform cost.

The design went in the opposite direction. Engines explain what happened; the progress layer owns normalization and persistence. That sounds like ordinary separation of concerns, but in practice it decides who owns historical truth.

Game result flow from a Blupoli engine through local durable persistence and future platform consumers
In September, results passed through a local queue and browser history. Authenticated synchronization with Supabase came later. Open the full-size diagram

GameResult v2: one sentence shared by very different games

The central change was a versioned GameResult contract. In September 2026, that contract was at version 2; the current normalizer declares version 3. The goal is not to pretend every game produces identical information. It is to give all games a small common vocabulary, while leaving room for measurements that genuinely belong to one engine or one puzzle family.

export const GAME_RESULT_SCHEMA_VERSION = 2;
export const GAME_RESULT_EVENT = 'blupoli:game-result';

export function normalizeGameResult(input = {}) {
  const gameId = String(input.gameId || input.gameSlug || '').trim();
  if (!gameId) return null;

  const status = VALID_STATUSES.has(input.status)
    ? input.status
    : (input.completed === false ? 'abandoned' : 'completed');

  return {
    id: String(input.id || input.sessionId || createGameResultId()),
    schemaVersion: GAME_RESULT_SCHEMA_VERSION,
    gameId,
    gameSlug: gameId,
    mode: VALID_MODES.has(input.mode) ? input.mode : 'puzzle',
    status,
    completed: status === 'completed',
    result: String(input.result || input.outcome || 'completed'),
    metadata: collectGameResultMetadata(input)
  };
}

Several choices in that snippet matter beyond syntax. The explicit schema version admits that the contract will evolve. Old history can be recognized as old history instead of being silently treated as if it had always contained today’s fields. Compatibility aliases matter for the same reason: gameSlug still maps to gameId, and outcome still maps to result. The new contract does not require a destructive one-day rewrite of every consumer.

The third choice is the escape hatch with discipline: metadata. We do not want to add a universal field each time one puzzle invents an interesting measurement. PolyPivot may care about dual pivots or overlap-related information. Sudoku should not suddenly be forced to understand those concepts. A field earns promotion into the shared contract when it becomes useful across several families. Until then, JSON-safe game-specific details can remain metadata.

The normalizer also deliberately refuses to fabricate missing measurements. If an engine does not know its move count, it stays unknown. If an old aggregate tells us a player solved fifty puzzles but cannot tell us when or how long they took, we do not generate fictional historical sessions. A partially populated but truthful dashboard is better than a complete-looking record built from guesses.

The gap between v2 and today’s v3 also shows how the contract can grow without erasing its origin. The current normalizer validates game identity and dates, and accepts optional measures such as undo, redo and corrections. Durations and most counters remain null when an engine did not supply them; resets have an explicit zero default. The version tells a consumer which record shape it is reading. Compatibility aliases preserve older names, but they do not promise that every future change will be automatic: evolution still needs deliberate decisions and tests.

A game session begins before anything is solved

One conceptual correction was to stop treating a result as something that exists only after success. The shared host opens a platform session when a playable game starts. If the player navigates away, refreshes or leaves before completion, that session can close as abandoned. This lets the product distinguish “I started a game” from “I completed a game”, which is necessary for completion rates, attempt counts and any future analytics that should not only remember victories.

The host now gives engines a small set of high-level hooks. They can update session details, increment moves, increment hints and record genuine player resets. They do not need to know where IndexedDB lives or how the durable queue works.

const updateSession = (details = {}) =>
  game?.slug ? updateGameSession(game.slug, details) : null;

const trackMove = (count = 1) =>
  game?.slug ? incrementGameSessionMetric(game.slug, 'moves', count) : null;

const trackHint = (count = 1) =>
  game?.slug ? incrementGameSessionMetric(game.slug, 'hints', count) : null;

const trackReset = (details = {}) => {
  if (!game?.slug) return null;
  return resetGameSession(game.slug, details);
};

This API does not force every engine into the same internal architecture. An engine that owns its timer can provide final duration. Another can rely on host timing. A competitive engine closes with markCompetitiveResult; a normal puzzle can use solved. Uniformity is created at the persisted boundary, not by erasing useful differences inside game code.

The local queue exists because browser shutdown is hostile timing

Browser persistence sounds easy until a tab closes at exactly the wrong moment. IndexedDB is a sensible home for detailed history, but an asynchronous transaction launched during navigation is not something we should treat as immortal. The repository therefore writes normalized results to a small durable localStorage queue before asynchronously flushing them into IndexedDB.

In the September 19 milestone, blupoli:game-result marked durable local acceptance; it did not claim that a result had reached the cloud. That describes the implementation at the time, not the full storage path today. The shared contract still separates a game finishing from the transport that carries its history, so achievements and challenge logic need not depend on IndexedDB timing or network availability.

The September 2026 path was engine → shared progress API → normalized GameResult → durable local queue → IndexedDB history → dashboard/export. That is the historical path, not today’s backend architecture. ADR 0004 later established Supabase as Blupoli’s shared account and application-data backend: authenticated progress uses Supabase identity and game_events through an idempotent RPC. Firebase remains the static Hosting/CDN provider. The local-first boundary still matters because play does not depend on a backend round trip.

Why accounts did not come first

Daily challenges and streaks naturally trigger the question: if progress is becoming important, should registration and profiles be implemented before anything else? Technically, that would be defensible. Product-wise, it would have shifted the project into a different problem too early. Blupoli still has games to finish, interfaces to settle and mechanics to refine. Authentication introduces identity recovery, privacy decisions, synchronization conflicts, account states and long-term security obligations.

At that September milestone, stable result IDs and a versioned schema prepared a clean boundary while the dashboard and export worked without an account. The Firestore references belonged to an earlier proposal; they should not be mistaken for the system that followed. The current product contract names Supabase as backend and Firebase as Hosting. This distinction keeps the historical decision readable without describing an obsolete plan as a live service.

For that phase, local-first kept the interface honest: My Progress described browser-local history rather than implying a signed-in account. The platform later added authenticated Supabase event persistence; that does not erase the value of offline-capable play. Today, unfinished boards remain local while completed account progress is sent to Supabase. This dated update supplies the present-day context missing from the original milestone.

The home page stopped being a landing page

Once progress had more structure, the Puzzles home could stop behaving like a catalogue cover. The redesign turns it into a local-first lobby. It greets according to time of day, surfaces a continue-playing area, recent games, favorites, recommendations, intention-based shortcuts and progress context.

“Recommendations” can sound more elaborate than the current system needs to be. We are not building an opaque machine-learning service. The code uses understandable local signals: which categories the player has spent time in, whether a game is still untried, and a penalty for repeatedly offering something already familiar. That simplicity is useful. The experience becomes personal without shipping puzzle history to a server or creating an advertising-style profile.

Today’s challenges follow the same philosophy. The current lobby can select three browser-local challenges that change with the day and track whether they have been completed. Meanwhile, the result contract already reserves isDaily for the future point where an official challenge has its own stable identity and rules. The product surface can evolve before the remote system, as long as it does not contradict the eventual contract.

Streak display is similarly based on measured activity rather than recomputing everything from thousands of detailed sessions every time the home loads. blupoli.activity.v1 stores a bounded daily aggregate. Detailed history and fast aggregate state solve different questions, so keeping both is intentional rather than redundant.

My Progress needed to become more than attractive counters

The second obvious user-facing change is the dashboard. Statistics pages are easy to make visually impressive while saying very little. The goal here was to make each section answer a real question with measured data.

The top-level KPIs include completed games, attempts, measured play time, active days, current streak and completion rate. Trends can be viewed over several time ranges and switch between completions, plays and minutes. A heatmap shows recent activity. Category analysis describes where play is concentrated. Records surface best times and move counts when those measurements exist. Achievements are currently local milestones derived from progress. Recent history shows completed and abandoned sessions with variant, duration and move information when available.

The analytics functions are separated from storage. That separation remains useful as the source evolves: in September, the dashboard read IndexedDB; under the current contract, authenticated event history is stored in Supabase. Charts can consume canonical results without depending directly on a particular backend.

There is also explicit copy for existing players. Aggregate totals from older versions are preserved, while detailed per-session history becomes richer from this version forward. We could synthesize old sessions to fill every graph immediately. We do not, because old aggregates do not contain real dates, durations or event order. Keeping the total and admitting the detailed timeline starts now is less flashy and more accurate.

Compatibility without letting the old brand own the new runtime

This work landed alongside a broad cleanup of identifiers inherited from the previous product namespace. Storage made that more delicate than changing visible strings. If someone already had progress under the legacy v3 progress key, simply switching source to blupoli.progress.v3 would have turned a branding cleanup into data loss.

The runtime now migrates old aggregate progress into the Blupoli namespace and removes legacy keys after a successful transfer. Related onboarding and theme/runtime state followed the same principle. Compatibility should preserve users, not preserve obsolete contracts forever. New code speaks Blupoli; the migration layer knows how to recognize what came before.

Tests now cover that boundary. The shared runtime contract verifies current Blupoli globals. Migration tests check that progress moves without loss. Coverage checks make sure available games have a completion path into the common result layer. Branding becomes a technical namespace decision as well as a visual one.

The coverage gate changes what “finished game” means

One of the most useful consequences of GameResult is not visible in the UI at all. The repository now has a coverage gate for available games. A public game is not fully integrated if there is no verified path for its play session to close through the shared contract.

Previously, “finished” might have meant that the engine generates a board, accepts input and can detect a solution. The platform definition is becoming stricter. Opening a game should create a session. Leaving an unfinished attempt should produce abandonment. A solved puzzle should close as solved. Competitive games should produce win, loss or draw. Shared metrics should be provided when the engine actually knows them. Engines should not maintain a competing platform history.

This mirrors the earlier publishing work. A game was no longer considered public merely because its directory existed. Now integration quality is also moving away from “the board works” toward “the game satisfies platform contracts”. Mature products are usually built by gradually making definitions like this less ambiguous.

Save state and game history are different jobs

The new contract also forced an important distinction between resumable engine state and platform history. They are related, but they are not the same data. A game may need to persist exact cell positions, an undo stack, a pseudo-random generator state, candidates entered by the player or which piece is selected. Only that engine knows how to reconstruct its interactive state correctly.

GameResult does not attempt to resume anything. It describes a closed or abandoned experience well enough for history, statistics and cross-game systems. Mixing the two would produce either giant platform-history records or a lowest-common-denominator save format that loses engine-specific information. The documentation therefore explicitly allows game-specific save state while forbidding each engine from inventing an independent platform-progress history.

This boundary matters now that authenticated progress can be used across sessions and devices. Results and resumable board snapshots remain different kinds of data: the former describe completed events, while unfinished boards remain local under the current persistence contract. Keeping those responsibilities distinct avoids assuming that every piece of browser state belongs in the remote event history.

What the contract enabled and what remains open

There is a meaningful difference between a vague “this architecture could support achievements someday” and an actual extension point. blupoli:game-result is an extension point. An achievement system can listen for accepted results. A daily challenge can inspect isDaily, game identity and puzzle identity. The current sync service sends versioned game events with stable event IDs to Supabase; profile summaries can be recomputed from canonical history instead of trusting dozens of unrelated counters.

The systems described in this section were not part of the September 2026 milestone. Subsequent work added account identity and remote persistence, so the question is no longer whether identity will arrive. The lasting value of the shared contract is that games can record a coherent result without each engine owning its own account or synchronization code. Today, engines can stay focused on play while the platform handles the shared result boundary.

The transferable lesson is narrower: a shared result format does not require every game to share its internal rules. The Puzzles contract keeps common measurements while allowing game-specific metadata, and each engine remains responsible for its domain. That boundary prevents an abstraction from growing merely because two screens use the same word.

Daily play is now a contract question, not a page question

The daily-challenge idea is a good example of why the data model mattered before the full feature. A daily card on the home page is easy to render. The harder questions arrive one layer later: what makes a run “the” daily attempt, how is the puzzle identity represented, what happens when the same game is opened outside the challenge, and which piece of code decides whether completion counts toward that day?

By reserving isDaily in the common result and allowing the host to derive it from launch context, we avoid teaching a future challenge service the internals of each engine. The challenge system can choose a game and puzzle identity; the host can mark the session; the engine can remain focused on its mechanic. That does not solve official challenge generation or anti-cheat concerns, but it locates the integration boundary before those requirements arrive.

It also prevents the home page from becoming the owner of challenge truth. A UI card can disappear, reload or be opened from another route. The result is the durable record. If daily play later spans devices, that distinction becomes even more important: presentation decides how the challenge is discovered, while the canonical result decides what the player actually completed.

What remains deliberately unfinished

The September lobby offered local daily selections rather than an official server-defined challenge catalogue with recovery rules. Its achievements were derived milestones, not a global rewards service. The account and authenticated-progress backend was established later on Supabase, while Firebase remains the static Hosting/CDN provider. This later architecture belongs to the product’s evolution after the milestone; it should not be retroactively attributed to that change.

We will also need to watch metadata. Extensibility is valuable only while it does not become a junk drawer. If several game families start recording the same concept under different names, that is evidence the concept deserves a first-class common field. Schema versioning gives us a controlled way to make that decision; it does not make the decision disappear.

There is also unglamorous integration work left. Older engines can progressively report moves, hints and resets where those measurements are genuinely available. The coverage gate proves they close results; the richness of those results will still depend on each engine providing truthful context.

A stable event is also a way to keep future features honest

The event boundary remains useful as achievements and remote sync evolve: consumers can depend on semantics rather than implementation timing. A listener receives a normalized result only after it has entered the durable local queue. It does not need to know whether IndexedDB has already completed its transaction, whether the dashboard is open, or whether a network connection exists. That gives future features a clean place to react without turning the progress repository into a grab bag of product callbacks.

A remote synchronizer should not redefine the local game-result event as “uploaded successfully”. Local consumers need a stable completion boundary even when a network is unavailable; remote synchronization can report its own status. One event should describe one boundary, so future features do not confuse a durable local result with confirmed cloud persistence.

The lesson: a platform begins when its parts no longer need to know one another

The biggest result of this phase is not the dashboard design or the number of new cards on the home page. It is that a future achievement system no longer needs to understand how Sudoku checks a grid or how PolyPivot rotates a piece. It can consume a normalized result. The dashboard does not need engine internals. It reads history. Future synchronization does not need one adapter per puzzle. It receives versioned contracts.

That is the beginning of a platform: not everything sharing the same implementation, but each part being able to own its responsibility without invading the others.

The visible side of this work is available in the new Blupoli Puzzles home and My Progress. The product context behind the redesign is covered in A new stage for Blupoli Puzzles. The next Devlog goes deep on the other major development from this same window: PolyPivot, a new spatial puzzle that also became a useful test of whether a very specific engine can still participate cleanly in shared platform history.

This is not a finished account system, a complete gamification layer or a promise that every result is already in the cloud. It is more useful than that right now: Blupoli has started to remember in a way its games can share.