Design Library

Plinko board - physics drop with payout ticker

plinko-board/ ยท 7 files

componentskill: design-taste-frontendcanvasgameinteractivedarkchart

Brief

The prompt that produced this. Re-run it to generate more in the same taste.

Aesthetic
arcade instrument - dark, saturated payout ramp, physical, reactive, percussive, legible under motion
Reference
gambas plinko game, canvas peg field with multiplier buckets
Intent
make the odds visible in the object itself - the ramp of bucket colours and the peg field are the explanation, so nobody has to read a payout table
Guardrails
always let the bucket colour ramp carry the payout curve, keep the ball readable against the peg field at every speed, announce how many balls are in play outside the canvas, settle the board to a visible resting state
never resolve a drop before its animation finishes, use colour alone to distinguish adjacent multipliers, let a flash outlast the event that caused it, block the control row while balls are falling

Install

pnpm dlx shadcn@latest add http://localhost:3040/r/plinko-board.json

Source

Meta block and library type import stripped - this is what pastes cleanly elsewhere.

'use client'

import Decimal from 'decimal.js'
import { useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { PlinkoGame, type PlinkoGameRef } from './plinko-game'

/**
 * A 12-row medium-risk ramp. The multipliers are the real payout curve shape -
 * steep at the edges, sub-1 through the middle - because the colour ramp only
 * reads correctly against a curve that actually falls away like this.
 */
const MULTIPLIERS = [10, 3, 1.6, 1.4, 1.1, 1, 0.5, 1, 1.1, 1.4, 1.6, 3, 10].map(
  (m) => new Decimal(m),
)

const RISKS = ['low', 'medium', 'high'] as const

export default function PlinkoBoard() {
  const ref = useRef<PlinkoGameRef>(null)
  const [risk, setRisk] = useState<(typeof RISKS)[number]>('medium')
  const [balance, setBalance] = useState(1000)
  const [active, setActive] = useState(0)

  return (
    <main className="min-h-[100dvh] bg-zinc-950 p-6 text-zinc-100">
      <div className="mx-auto flex max-w-3xl flex-col gap-5">
        {/* The board ships its own HUD - score, stake, active balls, risk - so
            the wrapper adds only what it does not have: a way to change risk
            and a way to drop. Repeating the balance here just made two
            disagreeing readouts. */}
        <div className="flex flex-wrap items-center gap-3">
          <fieldset className="flex items-center gap-2">
            <legend className="sr-only">Risk level</legend>
            {RISKS.map((r) => (
              <button
                key={r}
                type="button"
                onClick={() => setRisk(r)}
                aria-pressed={risk === r}
                className={`px-2.5 py-1 font-mono text-[11px] uppercase tracking-widest transition-colors ${
                  risk === r
                    ? 'bg-zinc-100 text-zinc-950'
                    : 'text-zinc-400 hover:bg-zinc-900 hover:text-zinc-100'
                }`}
              >
                {r}
              </button>
            ))}
          </fieldset>

          <Button
            type="button"
            onClick={() => ref.current?.dropBalls()}
            className="ml-auto rounded-none"
          >
            Drop ball
          </Button>
        </div>

        <PlinkoGame
          ref={ref}
          rows={12}
          ballCount={1}
          bet={1}
          balance={balance}
          riskLevel={risk}
          multipliers={MULTIPLIERS}
          onBalanceChange={(change) => setBalance((b) => b + change)}
          onActiveBallsChange={setActive}
        />

        <p aria-live="polite" className="text-center text-sm text-zinc-500">
          {active > 0 ? `${active} ball${active > 1 ? 's' : ''} in play` : 'Board at rest'}
        </p>
      </div>
    </main>
  )
}