Skip to main content

Mini-Games

Two mini-game widgets. Both are self-contained: they own their UI, their input handling, their localStorage state, and their accessibility plumbing. Wire the onSpin / onReveal callback to your reward grant flow (usually an event fire or a direct client.fireEvent).

<SpinWheel>

A weighted, SVG-based reward wheel with localStorage-persisted cooldown. Slices animate via a single CSS transform: rotate(...).

<SpinWheel
id="daily-spin"
cooldownMs={24 * 60 * 60 * 1000}
rewards={[
{ label: "10 G", reward: { kind: "currency", currency: "gold", amount: 10 } },
{ label: "Try Again", reward: { kind: "currency", currency: "gold", amount: 0 }, weight: 2 },
{ label: "50 G", reward: { kind: "currency", currency: "gold", amount: 50 } },
{ label: "Badge", reward: { kind: "badge", badgeId: "lucky-spin" }, weight: 0.5 },
]}
onSpin={async (reward) => {
await client.fireEvent({
name: "spinwheel.spun",
payload: { reward },
});
}}
/>

Props

PropTypeDefaultDescription
rewardsSpinWheelSlice[]Required. Slices in clockwise order from the top.
cooldownMsnumberRequired. Persisted in localStorage by id.
idstringRequired. Stable storage id (e.g. "daily-spin").
onSpin(reward: Reward) => void | Promise<void>Required. Called once when the wheel settles.
sizenumber280Diameter in CSS pixels.

SpinWheelSlice

FieldTypeDefaultDescription
rewardRewardRequired. What's granted if this slice wins.
labelstringRequired. Short label rendered inside the slice.
weightnumber1Optional. Relative weight for the weighted draw.
colorstring(palette pick)Optional CSS color for the slice fill.
tip

The winning slice is picked via crypto.getRandomValues() against a cumulative-weight scan — better entropy than Math.random(). Under prefers-reduced-motion, the wheel skips the animation and calls onSpin synchronously.


<ScratchCard>

Canvas-based scratch-to-reveal card. pointermove paints a transparent disc into the overlay via globalCompositeOperation = "destination-out". A rAF-throttled sampler counts erased pixels and fires onReveal exactly once at the threshold.

<ScratchCard
width={280}
height={160}
threshold={0.6}
overlayLabel="Scratch to reveal"
prize={<strong>+100 G</strong>}
onReveal={async () => {
await client.fireEvent({ name: "scratch.revealed", payload: {} });
}}
/>

Props

PropTypeDefaultDescription
prizeReactNodeRequired. Element shown underneath the overlay.
onReveal() => voidRequired. Fires once when erased ratio first crosses threshold.
thresholdnumber0.6Erased-pixel ratio that triggers reveal.
widthnumber280Canvas width in CSS pixels.
heightnumber160Canvas height in CSS pixels.
overlayColorstring--color-qk-mutedCSS color for the unscratched overlay.
overlayLabelstring"Scratch to reveal"Text painted on the unscratched overlay.
tip

The canvas is keyboard-focusable: Space or Enter triggers a 3-frame keyboard reveal animation (or an instant reveal under reduced motion). Touch input works out of the box — touch-action: none prevents vertical-drag scroll hijacking.

Accessibility notes

Both widgets:

  • Carry role="img" / role="status" regions with proper labels.
  • Respect prefers-reduced-motionSpinWheel skips the rotation, ScratchCard clears in one paint.
  • Announce the result via an aria-live="polite" region so screen readers don't miss the outcome.
  • Are keyboard-operable (Tab to focus, Enter/Space to act).