Nobody wins by reading your JavaScript
A scratch card is a few hundred lines of canvas and one decision: where the outcome comes from. Why that belongs on your server, what the browser can be trusted with, and the rule I built ten components around.
9 min
A scratch card is a few hundred lines of canvas code and one decision. The canvas part is the fun problem. The decision is where the outcome comes from, and if you get that wrong the whole mechanic can be beaten by somebody who knows how to open DevTools.
I have just put out Loyalty Patterns, ten free React components for the member-facing side of loyalty programs. Scratch cards, spin wheels, stamp cards, coupons, tier ladders. Ten components and one rule that none of them break: the component never decides an outcome.
That reads like architecture pedantry. It is the difference between running a promotion and carrying a liability.
The bug is not in the canvas
Nobody sets out to build a cheatable prize draw. It happens because the interesting problem is the foil. You are working out how to composite a brush stroke, how to sample coverage without wrecking the frame rate, how to make the thing feel like a fingernail on cardboard. Next to that, deciding who wins looks like a detail. So it gets written like one: an array of prizes with weights, a random number, a comparison.
Both halves of that ship to the browser. The weights sit in the bundle, the roll happens in front of the member, and the result exists in memory before a single pixel of foil comes off.
What that hands over is not one free prize. It is three separate things. The odds become readable, so anybody can work out the expected value of your campaign before deciding whether it is worth their time. The result becomes knowable before the reveal, which turns the animation into a formality. And whatever endpoint records a win becomes callable directly, on repeat, without the card.
The person who finds it is usually not an attacker, either. It is a developer at the brand poking around, or a member who opened the network tab because their card would not load. Once one of them posts it the campaign is finished, and you cannot fix it by shipping a patch, because every prize already handed out was handed out under the old odds.
A scratch card is a reveal, not a draw
Two verbs get collapsed into one component: deciding and showing. Only the second one belongs in the browser.
So the order is fixed. Your server draws, records, and returns a result. The component takes that result and performs it. It knows the outcome and has no way to compute one, which is a stronger property than a rule in a code review, because it still holds when somebody edits the component a year later. That is how the scratch card in the kit works, and it is why its props accept an outcome, or a function that fetches one, and never a table of prizes.
The gesture is theatre. The outcome arrived before the first stroke.
The obvious objection is latency. If the server decides, the member waits, and a card that pauses to think feels broken. The fix is to stop treating the request as a step in the sequence. Fire it the moment the member starts interacting, not on mount and not when the gesture ends. The round trip then happens underneath the scratching, which takes a second or two, and by the time enough foil is gone to reveal anything the answer has been sitting there a while. Nobody waits. And a card nobody touches never spends a draw.
What the browser can be trusted with
Plenty, as it turns out. Just nothing that decides anything:
- How much foil is gone, measured from actual pixels rather than inferred from elapsed time.
- Where the pointer has been, and whether it is still down.
- When to stop painting and start announcing.
- The animation, the easing, the sound, the confetti.
None of those can change who won. That is the entire test, and it is short enough to use in a code review: if a value living in the browser could change the outcome, it is in the wrong place.
This is a compliance problem, not a code-style problem
Prize promotions are money, and in most places they are regulated. A draw whose odds can be read out of the bundle is a draw whose odds have been published, whether or not you meant to publish them. A draw whose result can be requested repeatedly is not really a draw.
I am not going to quote you a statute. Jurisdictions differ, I am a designer rather than a lawyer, and anybody who tells you there is one rule for this has not shipped a promotion in two markets. What I can tell you is how the conversation goes once the mechanic reaches review, because I have had it more than once. The first question is where the decision gets made. The second is what record you keep of it. A client-side draw fails the first and has no answer at all to the second, because the only evidence it ever produced lived in a browser that has since been closed.
The cost of getting it wrong is not a bug report either. It is a number somebody has to explain in a finance review, plus a set of prizes you have already given away.
So how do you actually build one
Here are the decisions in the order they matter. They are more useful than my canvas code, which you can go and read anyway, and they are the same six decisions whether you write it yourself or copy the component.
- 01Decide nothing the component accepts a result, or a function that goes and gets one. If you are passing an array of prizes into a component, stop there and move it.
- 02Fetch on first contact not on mount, which spends a draw on everyone who scrolls past, and not when the gesture ends, which is exactly where the waiting becomes visible.
- 03Measure coverage, never estimate it sample the alpha channel and count. Stroke counts and elapsed time both vary with the device, the pointer, and how determined the person is.
- 04Reveal on a state change crossing the threshold moves the component into a new state. Anything hung off a transition or animation event breaks when the tab is backgrounded or the member has reduced motion switched on.
- 05Give the gesture an alternative a real, visible button that reveals the result without any scratching. Not a nicety, see below.
- 06Design the failure the network will drop between the gesture and the answer. That is a state with copy and a retry in it, not an exception you let bubble.
The accessibility part everyone skips
Scratching is a path-based gesture, and WCAG 2.5.1 puts those at Level A: anything relying on a path needs a single-pointer equivalent. So the reveal button is not a fallback you add if the sprint allows. It is the requirement, and the scratching is the enhancement.
It is also not mainly about keyboards, which is what I assumed for longer than I would like to admit. It is for hand tremor, for limited dexterity, for anyone holding a phone one-handed on a bus, for a trackpad in a hotel room. And it quietly covers the case where your canvas fails for a reason you never manage to reproduce, because a member who cannot scratch can still find out whether they won.
The instinct is that a reveal button ruins the game. It does not, because the scratching was never the game. Winning is the game. The card is packaging, and packaging that cannot be opened is a worse problem than packaging that opens two ways. The rest of what this involves is in the kit's accessibility notes.
One detail from those notes is worth pulling out. The result has to reach a screen reader exactly once, at the moment it becomes true, and the words are yours rather than mine. Every string in every component arrives as a prop, announcements included, which had a side effect I did not plan and now rely on: the copy type ends up being a complete inventory of the states a component can be in. If you cannot write the label, you have found a state you had not thought about.
Where the set came from
The reason there are ten of these and not one is that I kept running into the same three mistakes in unrelated products. The outcome computed in the browser. A gesture with no alternative. Copy hardcoded in English, roughly three sprints before somebody asks for a second locale. Fixing the same three things over and over is a strong hint that the fix belongs somewhere other than the current project.
So it is copy-in rather than a package. One `tokens.css` file plus one folder per component, and after that the files are yours to edit. There is a real cost to that: you do not get updates by bumping a version. What you get instead is a component you can change without arguing with a maintainer's abstraction, and for UI this brand-specific I would take that trade again. The installation notes are short, mostly because there genuinely is not much to say.
The part I am least confident anybody will use, and most pleased with, is that every component also emits a portable specification. It is a prompt carrying the behaviour contract, the state machine, the accessibility requirements and the compliance rules, each with its reasoning attached so an agent can tell which parts it is not allowed to trade away. The React build is the reference implementation rather than the dependency. If your stack is Vue or SwiftUI, take the spec and leave the code.
What it is not is a design system, and it assumes you already have one. Components read CSS custom properties and nothing else, no provider and no class-name convention, so theming is however many lines it takes to map your tokens onto `--lk-*`. Usually about twenty.
It is MIT, the source is on GitHub, and the playground will hand you the code with your own settings already in it. Take whatever is useful. The one thing I would ask is that you leave the decision where it is.