DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two numbers moved in opposite directions and the copy on four surfaces updated itself

CogniPrep has a free tier with two independent limits: how many practice games
you can play on a provider before the wall, and how many of your results show a
score. Last week one went down and the other went up.

export const FREE_PLAY_BUDGET_PER_PROVIDER = 5;  // was 15
export const FREE_SCORES_PER_PROVIDER = 3;       // was 1
Enter fullscreen mode Exit fullscreen mode

The interesting part is what did not have to change.

The number was effectively infinite

/**
 * Cut from 15 to 5 once the data showed 244 non-buyers had completed four or
 * more games while almost none of them ever reached the wall: at 15 the budget
 * was effectively unlimited for the way people actually practise.
 */
Enter fullscreen mode Exit fullscreen mode

A limit nobody reaches is not a limit, it is a line in the FAQ. The thing that
made this measurable was asking how many people got close to it rather than how
many hit it. Hitting a wall is a rare event by construction, so the hit count
tells you almost nothing; the distribution of how far people got tells you where
the wall actually sits relative to real behaviour.

The two limits also deliberately do not form a ladder:

/**
 * Premium deliberately does NOT lift this budget. Premium buys scores and
 * feedback reports, and unlocking a provider is what buys unlimited plays. The
 * two are independent, which is exactly what the pricing/help copy says.
 */
Enter fullscreen mode Exit fullscreen mode

So getPlayLimitForGame takes a tier it no longer reads:

export function getPlayLimitForGame(
  gameId: string,
  _tier: PlanTier,
  unlockedProviders: string[] = []
): number | null {
Enter fullscreen mode Exit fullscreen mode

The underscore parameter is kept for call-site compatibility. I go back and
forth on whether that is honest API design or a leftover, and I have left it
because deleting it would touch every caller to prove a point that the comment
already makes.

Three inside five, on purpose

Free scores (3) sit inside free plays (5). That ordering is the design: by the
time a free account runs out of plays on a provider, it has already seen a few
scores and then watched two results arrive blurred. The wall it meets second
is the one it has a reason to care about.

Inverting them would have produced the opposite experience, where you exhaust
your plays while every result is still visible and the score wall never arrives
at all.

Four surfaces, zero copy edits

The numbers are interpolated everywhere they are stated:

a: `Yes. Sign up free and play ${GAME_LIBRARY.filter((g) => g.freeTier).length} free
   practice games across every provider with no payment required - ${FREE_PLAY_BUDGET_PER_PROVIDER}
   free plays per provider ... Your first ${FREE_SCORES_PER_PROVIDER} scores with each
   provider are shown free.`
Enter fullscreen mode Exit fullscreen mode

That is the pricing FAQ. The signup page, the two paywall copy strings and the
support bot's knowledge file all do the same thing. So the product change was
two constants and a test, and the live site now reads:

Yes. Sign up free and play 77 free practice games across every provider with
no payment required - 5 free plays per provider, shared across that
provider's games - with no credit card needed. Your first 3 scores with each
provider are shown free.

Three numbers in that sentence and none of them is typed. The 77 is derived
from the library by a filter, so adding a free game updates the pitch.

The rule is decided on the server, at save time

The free score rule has one property that keeps it honest:

It is decided on the server at save time, from the sessions already in the
database, never from anything the client claims.

And three properties that make it fair:

  • Scoped per provider, not per account. Trying a second provider earns a second set of free scores, because a candidate invited to two different assessments deserves a taste of both.
  • Only scored games consume one. The personality style questionnaires have no number to reveal, so one of those neither shows a free score nor spends one. Without that rule, a candidate whose first game happens to be a questionnaire would spend an allowance on a result they never saw.
  • Replays count. Three plays of one game is three sessions, and it uses all three.

A query shape I had to convince myself about

The dashboard has to re-derive which of your past sessions are the free ones, so
it un-blurs exactly the cards the completion screen showed. The query fetches
the earliest N sessions per game, and the pure function picks the earliest N
per provider out of that:

The input is the projection getEarliestSessionsByGame returns, which holds
up to FREE_SCORES_PER_PROVIDER sessions per game. That is enough: a session
among the first N on its provider is necessarily among the first N on its own
game.

That one sentence is the whole justification for not paging through a user's
entire history. If a session is in the first three on a provider, at most two
sessions precede it anywhere, so at most two sessions precede it within its own
game. The per-game projection cannot miss it. One query, the rest in memory, and
the same pure function runs on the completion screen and in the save path.

Counting a wall that a server component rendered

Raising the free score count makes the score wall a later, rarer event, which
is only measurable if it is counted. Walls are rendered by server components,
which cannot fire a client analytics event, so there is a component whose entire
job is to exist:

export function PaywallShownRecorder({ wall, surface, provider = null, gameId = null }) {
  const tracked = useRef(false);

  useEffect(() => {
    if (tracked.current) return;
    tracked.current = true;
    paywallEvents.shown({ wall, surface, provider, gameId });
  }, [wall, surface, provider, gameId]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Two details earn their comment. The ref guard is there for React's development
double invoke, so a wall counts once rather than twice in development and once
in production, which is the kind of discrepancy that makes you distrust a
dashboard. And every prop is a primitive, so the effect's dependency array does
not re-fire when the page around it re-renders. Pass an object literal there and
you have built a counter that counts renders.

See it

  • cogniprep.app/pricing, under "Can I try before I buy?", is the sentence quoted above with all three numbers live. If the constants move again, that FAQ answer moves with them.
  • cogniprep.app/signup states the score allowance too. View source and search for "scores with each provider": the interpolated 3 is wrapped in React's <!-- --> text-node markers, which is the clearest possible evidence that the number is read from code rather than typed into a paragraph.
  • cogniprep.app/games lists the providers each budget is scoped to. The limits are per provider, so that page is also the list of separate free trials an account gets.

Top comments (0)