stream/ui
Browse the library

Bonk the bear

A cartoon bear sits on his rug; your cursor is the Hammer mallet. Bonk his head for X-eyes, dizzy stars, and a growing bump.

experimental

Try it

Bonks 0· Minding his own business.

The bear is sitting on his rug.

How it works

  • Inside the board the cursor is the Hammer lab's mallet. A swing anywhere is a whiff; on his head it's a bonk: he squashes, his eyes go X, stars circle, and a BONK! pops.
  • The bump on his crown grows a size per bonk (up to three) and goes down a size after each quiet spell, so let him rest.
  • Sounds are synthesized, no audio files: a wooden tok, his squeaky-toy eep in three voices, a boing as the bump grows, and a sparkle when he's seeing stars. All pitched above 300 Hz so laptop and phone speakers play them.
  • His head is a real button: Enter or Space bonks from the keyboard, and the count is announced. On touch the mallet jumps to your tap.
  • Reduced motion skips the swing, squash, orbit, and popups; the bonk still counts and the X-eyes and bump still show.

Install

Copy it, with its game rules, into a shadcn/ui project with the shadcn CLI, or open it in v0 to remix it. Or copy the source below.

bunx shadcn@latest add https://streamui.org/r/bear-lab.json
Open in v0 (opens in a new tab)

Source

src/components/demos/BearLab.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Volume2, VolumeX } from "lucide-react";
import { cn } from "@streamuilib/react";
import { blip, NOTE } from "@/lib/games/arcade-sound";
import { Mallet } from "@/lib/games/mallet";

/** Swing geometry, matching the Hammer lab's mallet: it pivots on the end of its handle. */
const PIVOT = { x: 100, y: 190 };
const ARM = 130;
const REST = -20;
const STRIKE = -55;
const SIZE = 0.62;

/** The bear's honey palette, set on the board. */
const PALETTE = {
  "--bear-fur": "oklch(0.64 0.09 62)",
  "--bear-dark": "oklch(0.5 0.08 55)",
  "--bear-light": "oklch(0.87 0.05 78)",
} as CSSProperties;

/** The bump grows a size per bonk, up to this, and shrinks back after a quiet spell. */
const MAX_BUMP = 3;
const COOL_DOWN = 2200; // ms without a bonk before the bump goes down a size

const WORDS = ["BONK!", "THUNK!", "BOP!", "DOINK!"];

/**
 * Cute sounds, pitched for laptop and phone speakers (nothing much below
 * 300 Hz, which small speakers can't play): a wooden tok for the mallet,
 * then the bear's squeaky-toy "eep!" in one of three voices.
 */
const SQUEAKS: Parameters<typeof blip>[0][] = [
  // "Eep!": a quick squeak up.
  [{ f: 880, to: 1760, at: 0.04, dur: 0.12, type: "sine", gain: 0.16 }],
  // "Ow-oo": up, then down.
  [{ f: 990, to: 1480, at: 0.04, dur: 0.08, type: "sine", gain: 0.15 }, { f: 1480, to: 830, at: 0.12, dur: 0.14, type: "sine", gain: 0.13 }],
  // "Mew": a soft fall.
  [{ f: 1320, to: 740, at: 0.04, dur: 0.18, type: "triangle", gain: 0.18 }],
];
/** The mallet's wooden tok on his head. */
const TOK: Parameters<typeof blip>[0] = [{ f: 760, to: 420, dur: 0.05, type: "square", gain: 0.06 }];
/** A springy boing as the bump pops up. */
const BOING: Parameters<typeof blip>[0] = [0, 1, 2, 3, 4].map((k) => ({ f: 420 + k * 70, to: 520 + k * 90, at: 0.16 + k * 0.04, dur: 0.05, type: "sine" as const, gain: 0.08 - k * 0.012 }));
/** Twinkling stars when he's seeing them: a little C-major sparkle. */
const SPARKLE: Parameters<typeof blip>[0] = [NOTE.C6, NOTE.E6, NOTE.G6, NOTE.E6].map((f, k) => ({ f, at: 0.22 + k * 0.07, dur: 0.12, type: "triangle" as const, gain: 0.06 }));
/** A miss: a soft swish through the air. */
const WHOOSH: Parameters<typeof blip>[0] = [{ f: 700, to: 320, dur: 0.14, type: "sine", gain: 0.07 }];

type Hit = { id: number; x: number; y: number; word: string; tilt: number };

/** Where the mallet's box goes so its head lands on (x, y) at the strike angle. */
function placeFor(x: number, y: number) {
  const r = (STRIKE * Math.PI) / 180;
  const pivotX = x - ARM * SIZE * Math.sin(r);
  const pivotY = y + ARM * SIZE * Math.cos(r) - 14;
  return { left: pivotX - PIVOT.x * SIZE, top: pivotY - PIVOT.y * SIZE };
}

/**
 * The bear, sitting, front on: round ears, a light muzzle, paws on his
 * knees, and feet out front with pads. `dazed` swaps his eyes for X's;
 * `bump` (0 to 3) grows a lump on his head; each new `bonks` count re-keys
 * the head so its squash plays again.
 */
function Bear({ dazed, bump, bonks }: { dazed: boolean; bump: number; bonks: number }) {
  const fur = "var(--bear-fur)";
  const dark = "var(--bear-dark)";
  const light = "var(--bear-light)";
  return (
    <svg viewBox="0 0 200 210" overflow="visible" className="size-full" aria-hidden>
      <g stroke="var(--ink)" strokeWidth="5" strokeLinejoin="round" strokeLinecap="round">
        {/* Body and belly. */}
        <ellipse cx="100" cy="150" rx="58" ry="50" fill={fur} />
        <ellipse cx="100" cy="158" rx="34" ry="32" fill={light} strokeWidth="4" />
        {/* Feet out front, with pads. */}
        {[62, 138].map((x) => (
          <g key={x}>
            <ellipse cx={x} cy="194" rx="22" ry="15" fill={fur} />
            <ellipse cx={x} cy="196" rx="11" ry="8" fill={light} strokeWidth="3.5" />
          </g>
        ))}
        {/* Arms resting on the knees. */}
        <path d="M52 124 C 36 140, 40 168, 58 172 C 70 168, 66 146, 70 132 Z" fill={fur} />
        <path d="M148 124 C 164 140, 160 168, 142 172 C 130 168, 134 146, 130 132 Z" fill={fur} />

        {/* The head squashes from its chin when bonked. */}
        <g key={bonks} className={bonks > 0 ? "bear-lab-squash" : undefined} style={{ transformOrigin: "100px 112px" }}>
          {/* Ears. */}
          {[54, 146].map((x) => (
            <g key={x}>
              <circle cx={x} cy="34" r="20" fill={fur} />
              <circle cx={x} cy="34" r="10" fill={dark} strokeWidth="3.5" />
            </g>
          ))}
          <circle cx="100" cy="70" r="50" fill={fur} />
          {/* The bump: a lump on the crown that grows a size per bonk. */}
          {bump > 0 && (
            <ellipse
              className="bear-lab-bump"
              cx="100"
              cy={26 - bump * 3}
              rx={8 + bump * 4}
              ry={6 + bump * 4}
              fill="oklch(0.78 0.12 25)"
            />
          )}
          {/* Muzzle, nose, mouth. */}
          <ellipse cx="100" cy="86" rx="24" ry="18" fill={light} strokeWidth="4" />
          <ellipse cx="100" cy="78" rx="9" ry="6" fill="var(--ink)" />
          <path d={dazed ? "M90 96 Q 95 92, 100 96 Q 105 100, 110 96" : "M92 92 Q 100 99, 108 92"} fill="none" strokeWidth="3.5" />
          {/* Eyes: dots, or X's while dazed. */}
          {dazed ? (
            <g strokeWidth="4">
              <path d="M74 58 L84 68 M84 58 L74 68" />
              <path d="M116 58 L126 68 M126 58 L116 68" />
            </g>
          ) : (
            <g stroke="none" fill="var(--ink)">
              <circle cx="79" cy="62" r="5.5" />
              <circle cx="121" cy="62" r="5.5" />
              <circle cx="81" cy="60" r="1.8" fill="var(--paper-raised)" />
              <circle cx="123" cy="60" r="1.8" fill="var(--paper-raised)" />
            </g>
          )}
          {/* Blush. */}
          <ellipse cx="66" cy="80" rx="7" ry="4" fill="var(--pink)" stroke="none" opacity="0.55" />
          <ellipse cx="134" cy="80" rx="7" ry="4" fill="var(--pink)" stroke="none" opacity="0.55" />
        </g>
      </g>
    </svg>
  );
}

/**
 * Three little stars circling above the bear's head in a flat ring. Each
 * star swings side to side and bobs up and down a quarter-beat apart, which
 * traces an ellipse; the stars sit a third of the way round from each other.
 */
function DizzyStars() {
  return (
    <span aria-hidden className="pointer-events-none absolute top-[4%] left-1/2 block">
      {[0, 1, 2].map((i) => (
        <span key={i} className="bear-lab-star-x absolute block" style={{ "--i": i } as CSSProperties}>
          <svg viewBox="-10 -10 20 20" className="bear-lab-star-y block size-6 -translate-1/2" style={{ "--i": i } as CSSProperties}>
            <path d="M0 -9 L2.6 -2.8 L9 -2.8 L3.8 1.2 L5.6 8 L0 4 L-5.6 8 L-3.8 1.2 L-9 -2.8 L-2.6 -2.8 Z" fill="var(--orange)" stroke="var(--ink)" strokeWidth="2" strokeLinejoin="round" />
          </svg>
        </span>
      ))}
    </span>
  );
}

/**
 * Bonk the bear: a cartoon bear sits on a rug, and inside the board your
 * cursor is the Hammer lab's mallet. Bonk his head and he squashes, his eyes
 * go X, stars circle his head, a BONK! pops, and a bump grows (it shrinks
 * back when you let him be). His head is a button, so Enter or Space swings
 * from the keyboard; on touch the mallet jumps to your tap. Motion holds
 * still for reduced motion, and the bonk still counts.
 */
export function BearLab() {
  const boardRef = useRef<HTMLDivElement>(null);
  const headRef = useRef<HTMLButtonElement>(null);
  const [aim, setAim] = useState<{ x: number; y: number } | null>(null);
  const [swing, setSwing] = useState(0);
  const [hits, setHits] = useState<Hit[]>([]);
  const [bonks, setBonks] = useState(0);
  const [bump, setBump] = useState(0);
  const [dazedUntil, setDazedUntil] = useState(0);
  const [now, setNow] = useState(0);
  const [muted, setMuted] = useState(false);
  const mutedRef = useRef(false);
  const bumpRef = useRef(0);
  const hitId = useRef(0);
  const statusId = useId();

  // The bump goes down a size after each quiet spell.
  useEffect(() => {
    bumpRef.current = bump;
    if (bump === 0) return;
    const t = setTimeout(() => setBump((b) => Math.max(0, b - 1)), COOL_DOWN);
    return () => clearTimeout(t);
  }, [bump, bonks]);

  // Wake from the daze on time (one timer per bonk).
  useEffect(() => {
    if (!dazedUntil) return;
    const t = setTimeout(() => setNow(Date.now()), Math.max(0, dazedUntil - Date.now()) + 10);
    return () => clearTimeout(t);
  }, [dazedUntil]);
  const dazed = dazedUntil > now;

  /** Swing at a point in board coordinates; `onBear` makes it count. */
  const swingAt = useCallback((x: number, y: number, onBear: boolean) => {
    setAim({ x, y });
    setSwing((n) => n + 1);
    if (!onBear) {
      if (!mutedRef.current) blip(WHOOSH);
      return;
    }
    const id = ++hitId.current;
    setHits((h) => [...h.slice(-4), { id, x, y, word: WORDS[id % WORDS.length], tilt: (id % 3) * 8 - 8 }]);
    setTimeout(() => setHits((h) => h.filter((x) => x.id !== id)), 650);
    setBonks((n) => n + 1);
    setBump((b) => Math.min(MAX_BUMP, b + 1));
    const until = Date.now() + 900;
    setDazedUntil(until);
    setNow(Date.now());
    if (!mutedRef.current) {
      // Tok, then his squeak (a different voice each time), a boing as the bump
      // grows, and a sparkle once he's seeing stars.
      const next = Math.min(MAX_BUMP, bumpRef.current + 1);
      blip([...TOK, ...SQUEAKS[id % SQUEAKS.length], ...(next > bumpRef.current ? BOING : []), ...(next >= MAX_BUMP ? SPARKLE : [])]);
    }
  }, []);

  const local = (e: { clientX: number; clientY: number }) => {
    const r = boardRef.current!.getBoundingClientRect();
    return { x: e.clientX - r.left, y: e.clientY - r.top };
  };

  /** The top of the bear's head, in board coordinates: where a keyboard bonk lands. */
  const crown = () => {
    const r = boardRef.current!.getBoundingClientRect();
    const h = headRef.current!.getBoundingClientRect();
    return { x: h.left + h.width / 2 - r.left, y: h.top + h.height * 0.15 - r.top };
  };

  function onPointerDown(e: PointerEvent) {
    if (e.button !== 0) return;
    // The head button handles its own bonk (so keyboard presses work too); this is a miss.
    if (!(e.target as HTMLElement).closest("[data-head]")) {
      const p = local(e);
      swingAt(p.x, p.y, false);
    }
  }

  const place = aim ? placeFor(aim.x, aim.y) : null;
  const mood = bonks === 0 ? "Minding his own business." : bump >= MAX_BUMP ? "Seeing stars." : dazed ? "Ow." : "Grumbling.";

  return (
    <div className="flex w-full flex-col items-center gap-4 text-ink">
      <style href="bear-lab" precedence="default">{`
        @keyframes bear-lab-swing {
          0% { transform: rotate(${REST}deg); }
          18% { transform: rotate(${STRIKE}deg); }
          36% { transform: rotate(${STRIKE}deg); }
          100% { transform: rotate(${REST}deg); }
        }
        @keyframes bear-lab-squash {
          0% { transform: scale(1, 1); }
          20% { transform: scale(1.12, 0.8); }
          45% { transform: scale(0.95, 1.06); }
          70% { transform: scale(1.02, 0.98); }
          100% { transform: scale(1, 1); }
        }
        @keyframes bear-lab-bump { from { transform: scale(0.4); } 60% { transform: scale(1.2); } to { transform: scale(1); } }
        @keyframes bear-lab-orbit-x { from { translate: -56px 0; } to { translate: 56px 0; } }
        @keyframes bear-lab-orbit-y { from { translate: 0 -9px; } to { translate: 0 9px; } }
        @keyframes bear-lab-breathe { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(1.015); } }
        @keyframes bear-lab-word {
          from { opacity: 1; transform: translate(-50%, -10%) scale(0.6) rotate(var(--tilt)); }
          30% { opacity: 1; transform: translate(-50%, -70%) scale(1.15) rotate(var(--tilt)); }
          to { opacity: 0; transform: translate(-50%, -120%) scale(1) rotate(var(--tilt)); }
        }
        @keyframes bear-lab-burst { from { opacity: 1; transform: translate(-50%, -50%) scale(0.6); } to { opacity: 0; transform: translate(-50%, -50%) scale(1.25); } }
        .bear-lab-swing { animation: bear-lab-swing 420ms cubic-bezier(0.3, 0, 0.3, 1) both; }
        .bear-lab-squash { animation: bear-lab-squash 480ms cubic-bezier(0.3, 0.9, 0.4, 1) 70ms both; }
        .bear-lab-bump { transform-box: fill-box; transform-origin: center bottom; animation: bear-lab-bump 300ms cubic-bezier(0.3, 1.4, 0.5, 1) both; }
        .bear-lab-star-x { animation: bear-lab-orbit-x 450ms ease-in-out calc(var(--i) * -300ms) infinite alternate; }
        .bear-lab-star-y { animation: bear-lab-orbit-y 450ms ease-in-out calc(var(--i) * -300ms - 225ms) infinite alternate; }
        .bear-lab-body { transform-origin: 50% 100%; animation: bear-lab-breathe 3.2s ease-in-out infinite; }
        .bear-lab-word { animation: bear-lab-word 650ms ease-out forwards; }
        .bear-lab-burst { animation: bear-lab-burst 260ms ease-out forwards; }
        @media (prefers-reduced-motion: reduce) {
          .bear-lab-swing { animation: none; transform: rotate(${STRIKE}deg); }
          .bear-lab-squash, .bear-lab-bump, .bear-lab-body { animation: none; }
          .bear-lab-star-x, .bear-lab-star-y { animation: none; }
          .bear-lab-star-x { translate: calc((var(--i) - 1) * 48px) 0; }
          .bear-lab-word, .bear-lab-burst { animation: none; opacity: 0; }
        }
      `}</style>

      <div className="flex w-full max-w-sm items-center justify-between font-mono text-xs uppercase">
        <span>
          Bonks <span className="font-bold tabular-nums">{bonks}</span>
          <span className="ml-2 text-ink-muted normal-case">· {mood}</span>
        </span>
        <button
          type="button"
          aria-label={muted ? "Turn sound on" : "Turn sound off"}
          aria-pressed={!muted}
          onClick={() => setMuted((m) => (mutedRef.current = !m))}
          className="grid size-6 place-items-center text-ink-muted hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
        >
          {muted ? <VolumeX aria-hidden className="size-4" /> : <Volume2 aria-hidden className="size-4" />}
        </button>
      </div>

      <div
        ref={boardRef}
        role="group"
        aria-label="Bear board. Click anywhere to swing the mallet; the bear's head is a button to bonk."
        aria-describedby={statusId}
        onPointerMove={(e) => e.pointerType === "mouse" && setAim(local(e))}
        onPointerLeave={(e) => e.pointerType === "mouse" && setAim(null)}
        onPointerDown={onPointerDown}
        className="relative h-80 w-full max-w-sm touch-none overflow-visible border-2 border-ink bg-blue-soft bg-dots shadow-brutal-lg select-none pointer-fine:cursor-none"
        style={PALETTE}
      >
        {/* The rug he sits on. */}
        <span aria-hidden className="absolute bottom-5 left-1/2 block h-10 w-64 -translate-x-1/2 rounded-[50%] border-2 border-ink bg-pink shadow-brutal-sm" />
        <span aria-hidden className="absolute bottom-7 left-1/2 block h-6 w-52 -translate-x-1/2 rounded-[50%] border-2 border-dashed border-ink/40" />

        {/* The bear. */}
        <div className="absolute bottom-7 left-1/2 h-60 w-56 -translate-x-1/2">
          <div className="bear-lab-body size-full">
            <Bear dazed={dazed} bump={bump} bonks={bonks} />
          </div>
          {/* His head, as a button over the drawing: the only thing that counts as a bonk. */}
          <button
            ref={headRef}
            type="button"
            data-head
            aria-label={`Bonk the bear. ${bonks} ${bonks === 1 ? "bonk" : "bonks"} so far.`}
            onClick={(e) => {
              const at = e.detail === 0 ? crown() : local(e);
              swingAt(at.x, at.y, true);
            }}
            className="absolute top-[4%] left-1/2 h-[50%] w-[62%] -translate-x-1/2 rounded-full focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-ink pointer-fine:cursor-none"
          />
          {dazed && <DizzyStars />}
        </div>

        {/* Impact: burst lines and a comic word. */}
        {hits.map((h) => (
          <span key={h.id} aria-hidden className="pointer-events-none absolute z-20" style={{ left: h.x, top: h.y }}>
            <svg viewBox="-60 -60 120 120" className="bear-lab-burst absolute top-0 left-0 size-20">
              <g stroke="var(--ink)" strokeWidth="7" strokeLinecap="round">
                <line x1="0" y1="-26" x2="0" y2="-50" /><line x1="22" y1="-16" x2="40" y2="-32" /><line x1="-22" y1="-16" x2="-40" y2="-32" />
                <line x1="28" y1="6" x2="50" y2="8" /><line x1="-28" y1="6" x2="-50" y2="8" />
              </g>
            </svg>
            <span
              className="bear-lab-word absolute top-0 left-0 border-2 border-ink bg-lime px-1.5 font-display text-sm whitespace-nowrap text-on-bright shadow-brutal-sm"
              style={{ "--tilt": `${h.tilt}deg` } as CSSProperties}
            >
              {h.word}
            </span>
          </span>
        ))}

        {/* With the cursor hidden, a small ring shows where the head will land. */}
        {aim && (
          <span
            aria-hidden
            className="pointer-events-none absolute z-10 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-ink bg-paper-raised"
            style={{ left: aim.x, top: aim.y }}
          />
        )}

        {/* The mallet, following the pointer (mouse), or jumping to the last swing (touch, keyboard). */}
        {place && (
          <div className="pointer-events-none absolute z-10" style={{ left: place.left, top: place.top, width: 200 * SIZE, height: 200 * SIZE }}>
            <div key={swing} className={cn("size-full", swing > 0 && "bear-lab-swing")} style={{ transformOrigin: `${PIVOT.x * SIZE}px ${PIVOT.y * SIZE}px`, transform: `rotate(${REST}deg)` }}>
              <Mallet className="size-full" />
            </div>
          </div>
        )}
      </div>

      <p id={statusId} aria-live="polite" className="sr-only">
        {bonks === 0 ? "The bear is sitting on his rug." : `Bonk! ${bonks} ${bonks === 1 ? "bonk" : "bonks"}. ${mood}`}
      </p>
    </div>
  );
}