a movie selector app https://movies.davidham.dev
  • Go 60.1%
  • TypeScript 37.6%
  • CSS 1.1%
  • Makefile 0.9%
  • HTML 0.2%
Find a file
Geoff 88202e4356
All checks were successful
release / goreleaser (push) Successful in 13m38s
release: v1.0.3
2026-08-30 13:18:35 +10:00
.forgejo yaml syntax error 2026-08-23 08:39:29 +10:00
core Revert the final's drag and scrolling camera 2026-08-30 12:42:40 +10:00
migrations feat: settle bracket matchups on a peg board 2026-08-30 11:42:44 +10:00
ui release: v1.0.3 2026-08-30 13:18:35 +10:00
utils fix: point the self-updater at the right server 2026-08-30 13:18:21 +10:00
.env.example fix: point the self-updater at the right server 2026-08-30 13:18:21 +10:00
.gitignore feat(api): group joining and server-run tournaments 2026-08-22 18:46:22 +10:00
.goreleaser.yaml yaml syntax error 2026-08-23 08:39:29 +10:00
CONTRIBUTING.md Initial commit 2026-08-21 17:13:53 +10:00
go.mod Initial commit 2026-08-21 17:13:53 +10:00
go.sum Initial commit 2026-08-21 17:13:53 +10:00
hooks_test.go feat: retire movies instead of deleting them 2026-08-30 11:30:17 +10:00
join_test.go feat(api): group joining and server-run tournaments 2026-08-22 18:46:22 +10:00
LICENSE Initial commit 2026-08-21 17:13:53 +10:00
main.go fix: point the self-updater at the right server 2026-08-30 13:18:21 +10:00
main_test.go feat(api): race-mode tournaments 2026-08-22 22:05:11 +10:00
Makefile fix: make make test work on a fresh clone 2026-08-30 11:45:20 +10:00
PLAN.md new plan for update 2026-08-30 08:36:18 +10:00
race_test.go feat(core): five duel layouts and a tube-fed final board 2026-08-30 12:00:51 +10:00
README.md fix: point the self-updater at the right server 2026-08-30 13:18:21 +10:00
release.sh Initial commit 2026-08-21 17:13:53 +10:00
tournament_test.go feat(api): race-mode tournaments 2026-08-22 22:05:11 +10:00

Movie Selector

Can't agree on a film? Put every suggestion into a group and let a coin-flip bracket decide.

Built on solidpb: a SolidJS frontend bundled into a single PocketBase (Go) binary.

How it works

  • Sign up with an email and password. You can change your display name later from the profile page.
  • Create a group and you get a join code. Share it and anyone with an account can join.
  • Everyone adds movies — just the name, no lookups or posters.
  • The group owner starts a tournament, and picks how it gets decided. The movie list locks while one is running, so the field can't shift underneath it.
  • A group can run as many tournaments as it likes, reusing the same movies or a fresh list each time.

Two ways to decide it

Bracket. Head-to-head knockout. Each matchup is settled on its own small peg board: two balls, bumpers and angled rails, first to the bottom goes through. There are five layouts and a matchup draws one at random, so a bracket does not run the same board five times over.

The final gets its own board and its own build-up: a 3-2-1 countdown and a starting whistle, then each ball drops down its own snug tube — one ball each, entered from opposite sides — before being spat out into a field about the size of the race board, with a paddle spinning about the centre line and a pair of bumpers swaying in opposite phase. The winner gets confetti and a flourish.

Moving obstacles are arranged to be fair by construction rather than fair on average: the swaying pair is mirrored across the centre line and moves in opposite phase, so the board is a mirror image of itself at every instant, and the paddle turns about the centre line. Tests assert both properties directly, as well as checking the win rate over 3000 runs.

Playing a round steps through its matchups one at a time and the bracket fills in behind. An odd number of movies gives one film a random bye, which passes straight through without being raced. The owner reveals a round at a time and everyone else refreshes to follow along.

Race. Every movie becomes a ball, and they all drop through one big peg board together. First to the bottom wins. Over in about ten seconds.

Both are real physics — gravity, bouncing off pegs, balls knocking each other off course — and it runs on the server, not in the browser. Simulating it per-browser would give every viewer a different winner, because floating point and frame timing drift apart, and would put the result within reach of whoever is watching. The server simulates the run, records every position at 60fps, and each browser replays that recording.

Only the seed is stored. The simulation is deterministic, so the seed plus the ball order is the entire run — including which of the five layouts a matchup was played on — and it is rebuilt in a few milliseconds whenever someone opens it.

The simulation also records which collisions are worth hearing, rate limited per ball and per frame so a busy board is a series of hits rather than a buzz. The client synthesises them with the Web Audio API — pegs, rails, bumpers, the countdown, the whistle and the winner's flourish — so there are no audio files. Sound can be muted from the header, and the choice is remembered.

Running it locally

You need Go 1.26.5+ (see go.mod) and Node 20+.

cp .env.example .env
cd ui && npm i && cd ..
make dev

Then open http://localhost:3000. The PocketBase admin UI is at http://127.0.0.1:8090/_/, using the superuser credentials from your .env.

make dev runs two processes: the Go server on port 8090 and the Vite dev server (with hot reload) on port 3000. The frontend talks to the API on 8090.

To run the way it will be deployed — one binary serving the built frontend:

make serve-prod

That serves everything from http://127.0.0.1:8090.

Other commands

Command What it does
make test Run the Go tests
make typecheck-ui Type-check the frontend
make build Build the binary into dist/
make types Regenerate the TypeScript types from the collections

make types rewrites ui/pocketbase.d.ts, ui/pocketbase-types.ts and ui/pocketbase-const.ts from the live schema. Run it after changing a migration; the generated files are committed.

Go 1.27 note. The Makefile builds with GOEXPERIMENT=nojsonv2. Go 1.27 turns on the rewritten encoding/json by default, which sends pocketbase v0.39.11's Collection.UnmarshalJSON into infinite recursion, so the server dies on startup. v0.39.11 is the latest release, so there is no upstream fix to take yet.

On Go 1.26.5 the bug does not occur and the flag is ignored, so the same commands work either way — there is no need to pin an older toolchain. (Go 1.24 and earlier reject the flag outright, but go.mod requires 1.26.5, so that case cannot arise.) If you build with a bare go build on 1.27, pass the flag yourself.

Deploying

Releases are cut from a tag and built by CI:

make release-patch
git push origin main --tags

That bumps the version, tags it, and the forgejo workflow runs GoReleaser to attach Linux amd64 and arm64 zips to a draft release. The draft has to be published by hand before anything can pick it up.

On the server, the app is a single binary next to a pb_data directory and an .env with ENV=production. To deploy a new version either drop the new binary in place and restart, or let the app fetch it:

./movie_selector update

That pulls the latest published release and replaces the binary in place. It needs GH_OWNER, GH_REPO, ARCHIVE_EXECUTABLE and GH_TOKEN set — see .env.example.

Back up pb_data before upgrading. Migrations run automatically on startup and are additive, but a copy of the directory is the only thing that undoes a surprise.

Nothing else is needed: schema changes apply themselves when the new binary starts, and existing records keep their ids. New columns arrive with their zero value, so for instance movies added before the retire feature come back as not retired and stay in their group's pool.

Layout

main.go                 wires up pocketbase, hooks and routes
core/
  bracket.go            pairing and byes (no pocketbase dependency)
  plinko.go             the physics, also pocketbase-free: the race board, the
                        five duel layouts and the final's tube board all run on
                        the same engine
  tournament.go         starting a bracket and playing a round
  race.go               generating and replaying a race
  hooks.go              invariants API rules can't express
  joincode.go           join code generation and normalising
  routes/               the three custom HTTP endpoints
migrations/             pocketbase schema, applied in order on startup
ui/src/
  routes/               one file per screen
  views/                pieces those screens are built from
  config/pocketbase/    client, auth and data access
  services/             endpoint calls, error formatting and the sound synth

Collections

Collection What it holds
user Accounts. Visible to people you share a group with
group Name, owner, and the permanent join code
groupMember Who is in which group. The owner gets a row too
movie A name, scoped to a group, and whether it has been retired
tournament Format, status, the round waiting to be played, and the winner
matchup One pairing, and the seed its duel was played from

A bracket tournament has matchup rows and advances a round at a time. A race has none: it stores a seed and the ball order, and is complete on creation.

Movies are retired rather than deleted. Matchups reference them without cascade, so a real delete would leave a finished bracket showing "?" where the title was. A retired movie drops out of the pool but stays on record, and a hook refuses to hard-delete one a tournament has used.

Endpoints beyond the collection API

Endpoint Why it exists
POST /api/groups/join Checks a code against groups the caller cannot see yet
POST /api/tournaments/start Starts a bracket or a race (format)
POST /api/tournaments/advance Flips the waiting round, then the next round or the winner
GET /api/tournaments/race Replays a race: board, 60fps frames, and the movie labels
GET /api/tournaments/duels Replays one bracket round's head-to-heads

tournament and matchup have no write rules and groupMember has no create rule, so bracket results and memberships can only come from server-side code.

Tests

make test

The bracket logic is pure and tested on its own: byes land only on odd counts and only once, the shuffle never drops or duplicates an entrant, the coin is fair over 10k flips, and every field size from 2 to 33 reduces to exactly one winner. The endpoints are covered by PocketBase API scenarios against a fixture built fresh from the migrations, including the permission cases (a plain member cannot start or play a round) and the pool lock.

Not in this version

Deliberately left out, per PLAN.md: no movie metadata, posters or TMDB lookups; no voting or ratings beyond the tournament itself; no live updates during a bracket (refresh to follow along); web only, though it installs as a PWA.

A race is watched independently by each person rather than in sync, since everyone replays the same recording whenever they open it.