The achievement problem begins before the first badge
Adding one achievement to one game is easy. Adding an achievement system to a platform with dozens of engines forces harder questions: who decides what “perfect” means, how an Ataxx win relates to a Sudoku solve, what happens when the catalogue grows, and how we stop every game from owning a private reward subsystem. Those questions defined PR #172.
We did not want to build a flashy collection first and repair the architecture later. Achievements had to be a derived layer over trustworthy game data. The implementation therefore separated four responsibilities: engines observe facts only they can know, GameResult preserves shared evidence, one central evaluator interprets catalogue data and rules, and a repository persists only the irreversible part of achievement history.
Scale became a design test. At the time there were 79 catalogued games. The engine could expand them into 1,080 base badges: 32 global, 100 category, and 948 game badges. A separate manifest held 237 specials, exactly three per game. The total is not a vanity metric; it demonstrates that the model can describe a large catalogue without 1,080 independent blocks of business logic.
Twelve slots per game without twelve copies of the same code
The evaluator defines twelve conceptual game positions: first completion, veteran, exploration, challenge, domain, perfection, clean play or an equivalent, records, three specials, and mastery. The position is shared, while its meaning adapts to the capabilities declared by each game. A game with several sizes and difficulties can reward coverage of those combinations; a game without those dimensions can use consistency or streak evidence instead.
Competitive games are not forced into a puzzle-solve model either. The system understands competitive mode, wins, losses, draws, and CPU difficulty. That allows the platform to share a progression grammar without pretending every experience ends in “solved.”
The repeated structure also helps the player read the collection. Familiar positions recur across games, so every title does not need an entirely new achievement taxonomy. At the same time, specials preserve personality. The common architecture does not make engines identical; it gives their differences a predictable way to be expressed.
Domain is derived from catalogue data
A central piece is domainDefinition(). Instead of keeping a hand-written checklist for each game, it reads declared dimensions such as size, difficulty or CPU difficulty, variant, and role. It then builds the combinations that define the relevant playable domain. Numberlink has a test that produces four sizes by four difficulties: sixteen cells.
A completed session can cover one of those cells. “Mastering” a game therefore does not simply mean repeating it many times. It means covering the surface that the product itself considers meaningful. The model supports excluded combinations, and games without multiple dimensions fall back to a base domain.
The maintenance benefit appears when the product changes. If a game gains a size or variant, the achievement domain can grow from canonical catalogue data. There is no second constant hidden in the reward engine that somebody has to remember to update. The achievement is derived from the product rather than from a manual copy of the product.
“Perfect” needed more than one profile
The word perfect sounds universal until Sudoku, Numberlink, Ball Sort, and Ataxx are placed side by side. Precision puzzles care about hints, corrections, and failed checks. Flow puzzles may care about retractions. A competitive perfect game has to be a win and may depend on resets, takebacks, or illegal attempts. One rule would be too lax for some engines and nonsensical for others.
The evaluator groups these differences into profiles such as PRECISION, FLOW, CLEAN, and COMPETITIVE. A profile represents a family of evidence. A handful of games retain a truly game-specific condition where their mechanics require it, but the evaluator does not need a full branch for every title.
This makes the badge explainable. A result is not perfect because a hidden counter reached one. It is perfect because a completed session exists and the metrics relevant to that kind of game were measured and remained within the profile’s conditions.
Missing data is not a perfect game
The earlier progress work established an important rule: unknown must not silently become zero. Achievements made that rule even more important. A legacy session that never recorded hints cannot be treated as proof of zero hints. Otherwise a platform update could award perfection retroactively with no supporting evidence.
isPerfectSession() therefore requires achievement-era tracking data. The session must carry achievementTrackingVersion and must not be marked as incomplete evidence. Capability metadata helps distinguish a real measured zero from a field the engine never recorded.
This may sacrifice some potential historical unlocks, but it preserves credibility. Rewards lose meaning when the platform cannot defend the conditions behind them. We chose not to award what cannot be proven rather than filling old history with convenient assumptions.
Specials had to stay specific without turning the evaluator into 79 engines
The three special achievements per game are where an overly generic architecture is most likely to fail. A good special should refer to the actual mechanic: a particular sequence, an unusual relationship, or a style of play only that engine can observe. If the central evaluator knew every rule individually, it would duplicate internal knowledge from the whole catalogue.
The solution separates observation from evaluation. Engines can attach achievementSignals to result metadata. content/achievement-specials.json declares which signal, set of signals, or recent value sequence a special needs. The evaluator only understands generic rule shapes.
The game knows when a mechanic-specific fact occurred; the achievement layer knows how facts become progression. Neither takes ownership of the other’s responsibility. Adding a special becomes primarily a matter of producing reliable evidence and declaring a rule, not expanding one enormous conditional module.
Measurement must not change the puzzle
QA exposed a regression that tested this boundary. Measuring the “Cascade” achievement in Logic Matrix and Zebra Grid analyzed logical consequences in a way that automatically modified candidates. The subsystem meant to observe play had changed the manual puzzle flow.
The fix separated analysis from mutation. Achievement instrumentation can detect that an action produces a cascade and record the signal without applying those logical consequences as automatic assistance. The player retains control and the achievement retains evidence.
That bug turned an architectural preference into a concrete rule: achievement signals should be observational whenever possible. If detecting a reward condition requires changing the board, the implementation deserves scrutiny because telemetry has probably crossed into game mechanics.
Mastery is not repeating one task until a bar fills
The game-master achievement combines several kinds of evidence. Tests require complete domain coverage, at least one perfect result, all three specials, and an established record. It is deliberately compositional: breadth, quality, mechanic-specific understanding, and performance.
This composition is only practical because GameResult keeps enough context. The evaluator can see which domain cells were covered, which sessions satisfy a perfect profile, which special signals appeared, and how personal marks evolved. If every engine owned private counters, combining those concepts would be much more fragile.
The same idea extends to category and global scope. Experience, exploration, collection, perfection, records, streaks, daily challenges, competitive play, and mastery are separate families. They are all derived from shared events plus catalogue context rather than from a second database of invisible counters.
Records need context, not just a better number
A best time is only comparable when it represents the same kind of challenge. The evaluator groups records by game and dimensions such as size, difficulty, variant, or role. Solving a small board faster does not overwrite a mark for a larger or harder configuration. They are different experiences and deserve separate references.
The first measurable session establishes a mark. Later sessions count as improvements only when they move in the correct direction. Ties do not create new record events. Tests cover this because treating an identical result as an improvement could inflate an entire reward family.
This reinforces the same principle used in the progress dashboard: metrics without context are easy to display and hard to interpret. Achievement logic should reward comparable improvement rather than statistical noise.
1,080 base badges became a catalogue guardrail
The catalogue test verifies platform-wide totals. With the 79 games in the repository at that point forced into a ready state for the test, evaluation produces 32 global definitions, 100 category definitions, and 948 game definitions. The 237 specials are also present as exactly three per game.
Those numbers detect structural loss. If a refactor accidentally removes a slot, a category group, or a set of specials, the suite does more than check that a function still returns an array. It notices that the collection no longer has the promised shape.
The totals are not meant to be frozen forever. The catalogue will grow. What matters is that the relationship among games, slots, and specials is verifiable and changes consciously when the source data changes.
Future games must not make current goals impossible
That same repository state contained 32 available games and 47 marked coming-soon. All 79 could have definitions prepared, but only playable games should participate in live progression denominators. If a future game already counted toward “master every game,” the goal would be impossible before the product could offer the game.
The evaluator understands ready, planned, and disabled. Available games default to ready; future games default to planned. Tests verify that every current playable game participates and every coming-soon entry stays out.
This lets us prepare work ahead of release without corrupting present progression. A future game can already have three specials in the manifest. When its product state changes, it can enter the active collection without a separate achievement architecture.
Categories are calculated over the same results
The ten category achievement positions do not need another copy of personal history. Catalogue data identifies category membership and the evaluator projects existing sessions into those groupings. If a game belongs to several categories, one GameResult can contribute to several views without being duplicated in storage.
That is increasingly useful as Blupoli gives categories a consistent visual and analytical identity. Explore, My progress, and Achievements can all use the same canonical taxonomy. The reward engine does not maintain a private classification.
If taxonomy evolves later, derived category values can be recalculated. Personal sessions remain unchanged. Reorganizing the catalogue changes how evidence is grouped for a question; it does not rewrite the evidence itself.
An unlock is irreversible even when the catalogue evolves
Persistence lives in blupoli.achievements.v1 and stores a deliberately small state: highest unlocked tier, timestamps for each tier, completion time, hidden-at-unlock state, and the catalogue version at unlock. It does not store every derived counter because those counters can be recalculated.
The merge operation is monotonic. If an earlier evaluation granted tier two and a later evaluation — perhaps against a larger catalogue — reports tier one or zero, the repository keeps tier two. The test suite explicitly verifies that unlocked tiers are never revoked.
This protects personal history. Adding a size, moving a game between categories, or widening a domain should not erase a badge the application awarded under valid conditions. Future progress may have a larger target; recognized history remains recognized.
Progress can move while unlocks cannot
This creates an important distinction between progress and achievement history. A percentage is a current calculation. If Numberlink gains another supported size, domain completion might move from 100 percent to a lower percentage because the target is now larger. That is accurate for the new catalogue. But a tier that was already unlocked remains part of the player’s past.
Keeping those concepts separate avoids awkward migrations. We do not need to freeze every denominator forever simply to preserve old rewards, and we do not need to revoke old rewards simply because denominators evolve. The repository remembers what happened; the evaluator describes where the player stands now.
Storing catalogVersionAtUnlock gives additional context. If the catalogue changes later, the system can still know under which version an unlock was granted. That is especially valuable in a product whose game collection is expected to expand over time.
Persisting less makes future synchronization simpler
Keeping only irreversible achievement history reduces the number of competing truths. Plays, streaks, domain coverage, and records can be derived from sessions. If all of those counters were also primary persisted state, every migration or device merge would need to reconcile cached totals against underlying events.
In the current model, sessions are evidence and achievement state is memory. That is a cleaner fit for a future account system: devices can merge stable results, then preserve the maximum known tier and unlock timestamps for each achievement.
Account synchronization does not exist yet; the product remains local-first. The point is architectural: introducing remote storage later should not require rebuilding the achievement model from scratch.
One event connects results and achievements
The achievement service listens for blupoli:game-result. When the progress repository accepts a result, the service loads catalogue data, the special manifest, and session history, runs evaluation, and merges new tiers. If there are new unlocks, it emits blupoli:achievement-unlocked.
Engines never call a “check my achievements” API. Their responsibility ends with producing a correct shared result and, where necessary, specific signals. This avoids circular dependencies and allows other platform systems to consume the same event.
The architecture is easy to state: a game produces evidence once; progress, achievements, streaks, and future features interpret it later. That reduces the chance that each subsystem invents its own version of what happened.
A signal contract gives engine authors a narrow responsibility
For an engine, adding achievement support should not mean importing the entire evaluator. The engine needs to record the metrics it already knows and emit a small signal when a special mechanic-specific condition occurs. The manifest then gives that signal meaning.
This narrow contract is important for maintainability. A future refactor of the achievement UI should not require touching Sudoku logic, and a new Sudoku special should not require understanding the internals of the collection screen. The interface between them is metadata on a completed result.
It also makes review easier. A code reviewer can ask two independent questions: is the engine signal correct for the mechanic, and is the declared achievement rule correct for the product? Mixing both concerns in one file would make regressions harder to isolate.
The collection needed a dedicated home
PR #178 added the visible second half: a first-class /achievements/ route with All, Global, Categories, Games, and Unlocked views, plus search and state filtering. Query parameters such as ?game= and ?category= allow the collection to open already contextualized.
My progress did not become a wall of hundreds of badges. It keeps upcoming goals and recent unlocks, then links into the full collection. Category rows show achievement progress, game cards display Achievements X/12, and the completion summary can open the collection for the game that just ended.
The separation clarifies intent. Progress answers “how am I playing?” Achievements answers “what have I earned and what is left?” They share evidence but need different information density and navigation.
Deep links turned achievements into a navigable system
The ?game= and ?category= entry points are more than convenience. They let the rest of the product refer to a precise slice of the collection. A Sudoku card can open twelve Sudoku achievements instead of dropping the player at the top of a huge list. A category row can reveal the category’s ten shared goals and related game progress.
This makes achievements composable with other surfaces. Completion, Explore, My progress, and future notifications can all link to the same canonical destination with context encoded in the URL. The collection does not need a different modal implementation for each source.
It also improves testability. E2E can open a known deep link and assert a concrete collection shape rather than depending on a sequence of manual clicks through unrelated UI.
Achievements became part of the app shell
Primary navigation became Home, Today, Explore, Achievements, My progress, and Streaks. That means responsive behaviour, routing, SEO, sitemap inclusion, and localization are no longer optional polish around a feature page; they are app-shell responsibilities.
The implementation supports Spanish, English, Italian, Portuguese, French, and German. E2E checks included twelve Sudoku game achievements, ten category achievements, collection search and filters, and the responsive six-destination shell.
A mathematically correct reward system is still a poor product if it is difficult to find or navigate. Giving the collection a primary destination completed the product side of the underlying engine work.
The completion screen is the natural discovery moment
When a session unlocks one or more tiers, the shared completion component groups those new achievements instead of opening a stack of interruptions. The result of the game remains primary; progression context follows it.
From that summary the player can jump to the collection already filtered to the relevant game. The completion screen acts as discovery, while the collection acts as durable memory. A temporary celebration therefore leads to a place where the achievement can be reviewed later.
This integration works because result, evaluation, and presentation are separate. The game engine does not know the achievement UI, and the achievement UI does not need to understand the game’s internal rules.
Validation covered rules, catalogue shape, and regressions
The test suite covers perfect profiles, domain coverage, record improvements, mastery composition, competitive streaks, planned games, and non-revoking persistence. It also verifies catalogue-level properties: 79 games, 237 specials, 1,080 base definitions, twelve achievements per currently available game, and exclusion of coming-soon games from live evaluation.
The reviewed branch also passed the project verification suite, full Playwright E2E, and Firebase previews. Castle Wall received an 8,000-seed stress run as part of the wider validation state. That test is not achievement logic, but it helped demonstrate that cross-cutting instrumentation had not destabilized an existing generator.
For a feature touching dozens of engines, “the new badge appears” is not enough. The implementation also has to prove that the observation layer did not change the games it observes.
What we deliberately did not build
There is no central evaluator full of if (game === ...) branches. Derived counters are not persisted as primary truth. Legacy sessions with incomplete evidence are not treated as perfect. Future games do not enter impossible denominators. Unlocked tiers are not revoked, and measuring a reward is not allowed to mutate puzzle state as a side effect.
Those limits explain much of the architecture. A small badge list would need less structure at first, but it would accumulate coupling quickly. We accepted more explicit contracts now so the next game does not create another parallel system.
Gamification therefore stops being decorative code attached after completion. It becomes a platform layer with data and product contracts, while still respecting the boundary of the game engine.
The broader lesson: rewards need a source of truth
An achievement only has value if the system can explain why it unlocked. GameResult supplies facts, catalogue data supplies context, engines supply specific signals only where necessary, the evaluator turns evidence into progress, persistence remembers the irreversible part, and the UI decides how to present it.
That chain leaves room for growth. A new game can declare dimensions and specials, move from planned to ready, and enter the collection without rewriting the central engine. A category can evolve and be recalculated without migrating personal sessions. A badge already earned remains part of history.
That is the difference between adding badges and building an achievement system for a platform: the first produces rewards; the second produces a stable way to prove why those rewards exist.
Related reading
The data foundation behind this layer is covered in Progress starts when you play. The earlier move toward shared platform contracts appears in From adding games to building a verifiable puzzle platform. The collection can be explored in Blupoli Puzzles Achievements.