Progress starts when you play, not when a board loads — Development · Blupoli

The real problem was not drawing more charts

When we started revisiting the progress screen in Blupoli Puzzles, the obvious route was to work from the outside in: add more cards, more charts, more comparisons and more filters. The code pointed to an earlier problem. If the platform could not answer a basic question such as “when does a game actually start?” with precision, every visualization built on top of it could look polished while telling the wrong story.

Some of the inherited local telemetry used a broad notion of plays. Depending on the storage version and the engine, opening, loading or generating a board could leave a signal that looked similar to having played. That was understandable when the goal was simply to know whether somebody had visited a game. It became insufficient once we wanted completion rates, abandoned attempts, difficulty-level comparisons or the evolution of personal bests.

PR #170 grew out of that mismatch. It was deliberately not the new achievement system, which was being designed separately, and it did not try to solve universal resumable snapshots yet. Its job was more fundamental: define a shared session lifecycle, migrate old data without inventing history, and make every new analytical layer depend on evidence the platform can actually prove.

A generated board is not a played game

The decision that changed the system most can be stated simply: a game starts on the player’s first meaningful interaction, not when a board happens to exist on screen. That boundary sounds semantic, but it reaches almost everything. A curious visit no longer inflates attempts. Restoring a saved board does not create a fresh session by itself. Requesting another board only abandons the previous one if that previous attempt genuinely started.

The shared lifecycle became explicit: gameGenerated, gameStarted, gameUpdated, gameCompleted and gameAbandoned. The common host understands those transitions while each engine keeps its puzzle-specific logic. The first useful action starts once; later actions update metrics; solving or finishing closes the result; replacing an active attempt marks it abandoned.

This removes a subtle class of cross-game bugs. If every engine informally decides what counts as a play, two charts that appear to compare the same thing may actually compare different events. Moving the meaning into a shared contract lets the platform talk about started, completed and unfinished games with one stable definition.

Game lifecycle diagram showing generated board, first interaction, active session, then completed or abandoned result
Statistics begin at the first real interaction. A generated or restored board is still not a started game.

Completion rate needed a trustworthy denominator

A completion rate is only useful when its denominator means something. With the new semantics, the formula is intentionally boring: completed games divided by genuinely started games. The same rule can be applied globally and, when enough evidence exists, by game, category, mode, size, difficulty and variant.

The important work is not the division. It is everything required to make the two numbers comparable. Browsed boards do not count as attempts. A started attempt that is replaced does count as abandoned. A competitive game may end in a win, loss or draw without pretending every case is equivalent to “puzzle solved.” Engines that do not know a metric do not fill the gap with a zero that looks like a measurement.

That last rule matters more than it first appears. Analytics code often turns “unknown” into zero because zero is easy to aggregate. But zero moves, zero mistakes and zero seconds all have real meanings. When historical storage cannot prove a value, the current contract keeps it null or leaves that dimension out. The dashboard may show less, but what it does show has a clear provenance.

Migrating without rewriting the past

Aggregate storage moved to blupoli.progress.v4, while general daily activity became blupoli.activity.v2. That forced an uncomfortable migration question: what should happen to installations that already contain counters from older versions when some old plays may include simple page visits?

The easy path would have been to reinterpret every historical value as a started game. It would preserve large counters and full-looking charts, but it would also fabricate precision. The chosen path was conservative. Potentially contaminated historical counts survive as legacyPlays for export and debugging, yet they do not inflate current completion rates. Only starts that can be proven from completed results are promoted into the new meaning.

Daily activity follows the same principle. Old play counters are not automatically transformed into detailed sessions. When the only defensible historical evidence is solved + competitive, that becomes the usable baseline. Dates, durations, sizes and difficulties that were never stored are not reconstructed. The result is less dramatic than a migration that magically fills every chart, but it is far more trustworthy.

Two storage layers answer two different kinds of question

The design intentionally keeps fast aggregates separate from detailed results. Aggregates answer frequent questions cheaply: how many games were genuinely started, how many were solved, what is the known best time, which games have been explored. Detailed history lives in IndexedDB, in the blupoli-progress database and its sessions store.

Every detailed result is normalized through the shared GameResult contract. It carries stable identifiers, mode, start and finish timestamps, status, outcome, duration, moves, hints, mistakes, size, difficulty, daily context and other optional measurements. Game-specific serializable metrics can live under metadata instead of forcing the common schema to grow a custom field for every engine.

This split also keeps the UI responsive. The dashboard does not have to scan all history every time it renders a summary. When it needs averages, medians, trends or personal marks, it can inspect detailed sessions. The architecture gets richer data without turning localStorage into an improvised relational database.

The tiny queue that protects the final result

IndexedDB is asynchronous. Usually that is exactly what we want, but completion creates a delicate timing window: the player may navigate away, reload or close the tab before the transaction finishes. The repository therefore writes a durable localStorage queue, blupoli.sessions.queue.v1, before the IndexedDB write completes.

On the next read or visit, pending items are flushed into the detailed store. The queue is not a second permanent history and it does not replace IndexedDB. It is a bridge across a navigation race. That small piece of plumbing matters because the result most worth preserving is also the one produced immediately before the user is most likely to leave the page.

Once a normalized result enters the durable queue, the repository emits blupoli:game-result. That boundary later became useful well beyond statistics. Other platform systems can subscribe to one result stream without integrating individually with every engine. The achievement system built afterwards relies on exactly that separation.

Pure analytics over a replaceable repository

static/js/progress-analytics.js is built around functions that accept sessions, aggregates and catalogue data and return series or summaries. They do not need to know whether the input came from IndexedDB, a test fixture or a future remote repository. That separation was intentional: the product remains local-first today, but the storage contract should not trap the analytics layer there forever.

The practical effect is that charts do not have to be rewritten when persistence changes. Daily series, period comparisons, completion rates, category performance and game-level trends are calculations over normalized data. A future sync layer can change where those records originate without changing what they mean.

It also makes the rules easier to test. A median calculation or activity streak can be exercised with a small synthetic fixture instead of booting every browser subsystem. In a platform with many engines, reducing the amount of environment required to prove a shared rule is a major maintenance win.

Which metrics were worth exposing

Once the session semantics were fixed, the dashboard could expand with much less risk. The implementation added started, completed and unfinished games; global completion rings; game and category exploration; performance by game, mode, size, difficulty and variant; and time statistics when detailed history exists.

The system calculates average, median and best time because they answer different questions. An average reacts to unusually long sessions. A median is more resistant to outliers. A best mark lets a player compare with their own strongest result. None of them is presented as historical truth when the necessary detailed records do not exist.

Global records use the same restraint. Best day, seven-day window and daily variety can be derived from recorded activity. A highly specific historical record cannot be invented from an old aggregate that never stored the required dimension. Designing the dashboard became partly an exercise in deciding what not to claim.

General progress is not the daily challenge streak

The work also clarified another boundary that could easily blur. General player activity and daily-challenge streaks are separate systems. blupoli.activity.v2 records real catalogue activity. Daily streaks advance only when a completed result explicitly carries isDaily.

That prevents strange incentives and misleading summaries. Completing five ordinary puzzles on Tuesday can enrich general statistics, but it should not keep alive a streak whose meaning is “completed the daily challenge.” At the same time, a daily challenge still enters the common history as a normal session with extra context, so we do not need two incompatible result formats.

This is a recurring architectural pattern: share the base event and specialize the interpretation. The engine reports what happened. Progress, streaks, achievements and a future sync layer decide how that result matters to them.

The dashboard could finally answer better questions

Before this iteration, a statistics screen mostly answered “how often was this game touched?” and “how often was it solved?” Afterwards, it can separate exploration from commitment. Which categories were tried? Where are attempts most often completed? Which game has the most real sessions? Are completion times improving? How many attempts are started and left unfinished?

Those questions are more useful because they do not reward simply opening many pages. They are also more nuanced. A low completion rate may indicate difficulty, limited time, experimentation or a deliberate choice to quit. The dashboard does not turn every number into a verdict. Its job is to present consistent evidence.

That influenced the visual tone as well. Rings and summaries should orient the player, not turn progress into a moral score. The information exists so somebody can understand their own activity and so later systems can build on a technically coherent history.

Why we refused to synthesize old sessions

One of the most deliberate limits was not creating fake detailed results from historical aggregates. We could have spread an old counter across days, estimated durations, or assigned default difficulties. The screen would look fuller immediately, but observed data and invented data would become indistinguishable.

Instead, detailed history begins when the platform can actually measure it. Old aggregates remain available as legacy context, but they are not disguised as modern precision. That decision also makes eventual synchronization cleaner: a remote GameResult can be treated as a real stable event, not as an estimate derived from an anonymous counter.

This became one of the less visible lessons of the work. Migration does not always mean converting everything into the newest shape. Sometimes the correct migration keeps a portion explicitly legacy because doing so is the only honest representation of what the system knows.

Local-first today, synchronizable later

The storage documentation is explicit that personal progress remains local. Firebase Analytics is not a player-progress database, and Firestore does not currently receive these sessions. The browser owns the immediate experience.

Even so, the future model can already be described without changing the contract: per-user sessions, per-game aggregates, daily activity and achievements. Results have stable IDs and the dashboard consumes a repository API rather than IndexedDB directly. When authentication arrives, sync can upsert by result ID while keeping IndexedDB as an offline cache.

Preparing that boundary now avoids a much more expensive rewrite later. We do not need accounts in order to design data that can survive an account. We also do not need to postpone useful local analytics until a backend exists. The current architecture lets those tracks evolve independently.

Tests became part of the definition

PR #170 was not only a storage and UI patch. Tests covered the result contract, available-game coverage, the dashboard and the shared runtime. Rules such as “generated is not started,” “the first meaningful interaction starts once,” and “a started attempt may close as abandoned” needed to stop being informal conventions.

That matters because the engines are intentionally different. Numberlink drags paths, Ataxx moves pieces, Dots and Boxes places edges, and Sudoku enters digits. The gesture that begins play is different, but the statistical consequence is shared: the first action that meaningfully changes or commits the attempt moves the session from prepared to started.

Cross-platform coverage does not replace engine-specific tests. It protects the shared layer so that a local fix cannot quietly turn a page visit back into a play or a competitive loss into a solved puzzle.

What we intentionally left out

Two areas were explicitly out of scope. The first was the definitive achievement system. It needed trustworthy results, but combining achievements with the data-contract rewrite would have made it harder to review which code fixed evidence and which code added product behaviour. The second was universal resumable snapshots and a complete “Continue playing” interface.

The corrected lifecycle helps both future areas. A resumable snapshot needs to know whether an attempt had started and which state it owns. An achievement needs to know whether the result it evaluates is real and which metrics were actually recorded. But having the foundation does not mean those layers are complete.

Keeping the scope narrow gave PR #170 a concrete success condition: statistics stop counting presence as play, and new detailed history becomes safe enough for richer analytics. Everything else can build on that.

What changed for players who will never see schema v4

Most players will never know that blupoli.progress.v4 exists, and they should not need to. The visible improvement is simpler: the numbers increasingly match what the player remembers doing. Opening a puzzle and backing out does not damage completion rate. Starting and abandoning one does become part of the history. Finishing a competitive game is stored with its real outcome.

The browser can also provide a richer sense of progression without requiring an account. Exploration, performance, times, trends and personal bests stay local while still feeling like a coherent profile of play. That matches the current product: immediate value first, cross-device sync later.

From a maintenance perspective, the change is even larger. New surfaces no longer need to ask each engine what “playing” means. They can consume one contract and focus on presenting the evidence.

The broader lesson: define the event before measuring more

The most reusable part of this iteration is not a particular chart. It is the reminder that analytics quality starts before visualization. If the base event is ambiguous, adding dimensions only multiplies the ambiguity. If the event is well defined, even a simple summary can be trusted.

For Blupoli Puzzles, that meant accepting that some historical data could not be cleanly upgraded, choosing a conservative migration, and putting the first meaningful interaction at the centre of the lifecycle. From there, averages, medians, abandonment, exploration and records stopped being decorative counters and became derivations of a measurable story.

The next systems no longer need to reopen the question “what is a game start?” That answer now lives in the contract, the storage model and the tests. The stability of that answer is exactly what lets progress, achievements and future account sync evolve without reinterpreting the past every time.

“Unfinished” and “abandoned” are not the same observation

Another useful distinction appeared while modelling the lifecycle. A generated board that nobody touches is not an unfinished game; statistically, it is not a game yet. A session that did start and is later replaced can be closed as abandoned. That difference keeps shallow exploration from looking like failure while still preserving genuine attempts that did not reach an ending.

It also forces some humility around browser navigation. Closing a tab does not always give application code a reliable final callback, so the system does not pretend it can magically classify every possible abandonment. It records the closures it can prove inside the controlled lifecycle. That restraint mirrors the migration strategy: a partial but defensible measurement is preferable to a complete number assembled from assumptions.

The result is a more useful semantic model. Aggregate “unfinished” can be derived from started minus completed activity, while detailed results can explicitly mark known abandonments. The two layers do not have to pretend they share the same observational power.

Competitive games share a session contract, not puzzle semantics

Ataxx and Dots and Boxes were practical reminders that a puzzle platform cannot assume everything ends in solved. The common result contract supports competitive mode and outcomes such as win, loss and draw. Analytics can therefore count a finished game without turning a loss into success or hiding it as if nothing happened.

Different product questions can then use the same evidence differently. For “completed games,” a loss or draw is a valid ending. For “mastered games” or certain achievements, the threshold may be different. The base data keeps the event faithful; higher layers apply their own meaning afterwards.

That reduces coupling. A competitive engine does not need to know how a progress chart is drawn, and the chart does not need to understand Ataxx capture rules. Both meet at a normalized result that describes mode, state and outcome.

The catalogue is part of the calculation too

Category analytics cannot be built from sessions alone. They need to know which games belong to which categories, whether a game is currently available and which mode it uses. That is why the analytics functions also receive canonical catalogue data. The catalogue supplies context; the history supplies evidence of play.

This matters even more now that a game can belong to more than one category. One session can contribute to multiple category views without duplicating the underlying result. The analytics layer projects the same event into the relevant families while storing the event only once.

That approach avoids making “category statistics” primary stored truth. They remain derived data that can be recalculated if taxonomy changes. We can refine discovery and classification later without migrating every historical session whenever a category boundary moves.

Exporting progress is an architectural test

The export snapshot includes aggregates, general activity, daily activity, achievement state and detailed results. Export may look secondary, but it acts as a useful architecture test: if we cannot describe the player’s state in one coherent envelope, our internal boundaries are probably unclear too.

Legacy information survives when necessary, but it is labelled as legacy. Old counters are not transformed into imaginary sessions. That transparency will matter if import or cross-device synchronization arrives later because conflict resolution can distinguish stable events from inherited summaries.

The same principle applies to clearing data. Resetting progress has to remove aggregates, activity, detailed sessions and associated achievement state. Privacy and reset behaviour are only trustworthy when the application knows every layer that constitutes personal progress.

What this foundation enables next

The progress screen will keep evolving. We are already exploring a navigation model where categories become a stronger axis and multiple game or category series can be selected and compared. That UI work becomes meaningful because the base now distinguishes real sessions, games, categories and performance dimensions consistently.

The dependency order is the important part. First define what happened. Then store it without inventing evidence. Next derive pure metrics. Only then decide how to visualize them. Skipping one of those layers tends to return later as a visual exception, a painful migration or a number nobody can explain.

PR #170 did not finish every future progress feature. It finished something more useful: a shared definition strict enough that future work does not need to begin by arguing about what “playing a game” means.

Related reading

This work continues the transition described in From adding games to building a verifiable puzzle platform and connects directly to the Blupoli Puzzles progress dashboard. For the product and responsive context, see From the announced catalogue to a usable platform.