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 statenever 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>
)
}