Putt-Putt
@poe

Putt-Putt

Multiplayer mini-golf for 2-4 friends. Play 9 holes at your own pace and compete for the highest arcade score.

About this tile

Multiplayer mini-golf for the Poe app platform. 2–4 players in a chat room, 9 hardcoded holes, drag-back-to-shoot mechanics, live leaderboard with arcade-style scoring.

Designed mobile-first portrait (390×844 design viewport). Desktop is supported but the target experience is a player on a phone, in a chat with friends, taking turns through the course at their own pace.

What you can do

  • Play 9 holes with bumpers, water, sand, windmills, and a current-driven "ramp jump." Each hole has a server-known par; your score per hole is computed server-side from a deterministic formula (base − over-par cost + under-par bonus + hole-in-one bonus + capped bumper bonus − hazard penalty).
  • Live leaderboard. Top 3 on the HUD pill; tap for the full scorecard with strokes + bonus delta per hole. Sort: points desc → strokes asc → finish time asc.
  • Play again. First player in the chat becomes the course creator and can tap "Play again" to start a new round. If the creator leaves, anyone else may.
  • Practice solo. A "Practice solo" CTA on the waiting screen lets a single player run the course while waiting for friends to join.

Built on synced-store

Per the platform pattern: one singleton course row + an accumulating holeResults table per (userId, holeIndex, courseVersion). Mutators run optimistically on the client + authoritatively on the server + on rebase, so wall-clock values must come in via input.now (not Date.now() inside the handler). See [synced-store/schema.ts](./synced-store/schema.ts) and [synced-store/mutators.ts](./synced-store/mutators.ts).

Player roster is $users (room-model auto-seeded). Display names come from $userInfo. Per-player ball color is assigned deterministically from addedAt order in [ui/colors.ts](./ui/colors.ts).

Architecture

`` poe-tiles/putt-putt/ ├── app/src/entry.tsx # React 19 createRoot + <App> ├── synced-store/ │ ├── schema.ts # course singleton + holeResults table │ ├── mutators.ts # submitHoleResult, resetCourse │ ├── game-logic.ts # pure computePoints() │ ├── constants.ts # TOTAL_HOLES, MAX_STROKES, HOLES (par + name) │ ├── client-config.ts │ └── backend-config.ts ├── ui/ │ ├── App.tsx # routes: waiting | playing | finished │ ├── Course.tsx # canvas + pointer events + rAF loop │ ├── Hud.tsx # leaderboard pill + hole label │ ├── Scorecard.tsx # full modal scorecard │ ├── physics.ts # pure: stepBall, walls, bumpers, hazards, sink │ ├── render.ts # pure canvas drawing │ ├── holes.ts # 9-hole geometry (asserted to match HOLES) │ └── colors.ts # deterministic per-user palette └── tests/ ├── mutators.test.ts # server-side trust posture ├── app.test.happydom.tsx # routing + leaderboard derivation ├── e2e.test.playwright.ts # 2-player full-round via simulateShot seam └── setup-dom.ts # canvas2d + ResizeObserver stubs ``

Trust model

Client-submitted, server-bounded — same posture as poe-tiles/top-down-racer. The mutator bounds every numeric input via Zod, looks up par from the server-known HOLES constant (never from input), and computes points server-side via computePoints. Cheese is possible at the client level; the scoring math runs on validated, server-known constants. See [PRINCIPLES.md](../../PRINCIPLES.md) "every variable-size input must be bounded" — every limit here is a named constant in [constants.ts](./synced-store/constants.ts).

Test seam

In every build, window.__puttPutt.simulateShot({ holeIndex, aimX, aimY, power }) is exposed for deterministic Playwright drives. The seam plays one shot, polls until sunk (or auto-nudges toward the cup if the first shot under-shoots), and resolves with { strokes, bumperHits, hazardHits, holedInOne }. App.tsx wires this to submitHoleResult like a real sink would.

Dev / test commands

```sh

Local dev server (Vite + Bun)

bun run dev

All unit + happydom tests (fast)

bun run test

Build the bundle

bun run build

Playwright e2e (needs dist/, slower)

bun run test:playwright

Type-check

bun run type-check

Lint

bun run lint

Regenerate the app's profile screenshot (uses bun run regenerate-screenshot)

bun run regenerate-screenshot ```

Mobile-first

Portrait-only design viewport: 390×844 (iPhone 13). Safe-area-insets respected on the root container in [app/styles.css](./app/styles.css). Touch action none on the canvas to prevent scroll-conflicts with drag-to-aim. viewport-fit=cover set in [index.html](./app/index.html).