a movie selector app
  • Go 60.1%
  • TypeScript 36.8%
  • CSS 1.6%
  • Makefile 1.1%
  • HTML 0.3%
  • Other 0.1%
Find a file
davenh99 3709e98c1d
All checks were successful
release / goreleaser (push) Successful in 13m43s
release: v1.0.1
2026-08-23 08:39:50 +10:00
.forgejo yaml syntax error 2026-08-23 08:39:29 +10:00
core feat(api): race-mode tournaments 2026-08-22 22:05:11 +10:00
migrations feat(api): race-mode tournaments 2026-08-22 22:05:11 +10:00
ui release: v1.0.1 2026-08-23 08:39:50 +10:00
utils Initial commit 2026-08-21 17:13:53 +10:00
.env.example chore: add .env.example for local development 2026-08-22 18:30:05 +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(api): group joining and server-run tournaments 2026-08-22 18:46:22 +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: drop the changelog plugin, which broke signup 2026-08-22 18:54:46 +10:00
main_test.go feat(api): race-mode tournaments 2026-08-22 22:05:11 +10:00
Makefile docs: correct the Go version note 2026-08-22 19:22:03 +10:00
PLAN.md add plan to build 2026-08-21 17:32:08 +10:00
race_test.go feat(api): race-mode tournaments 2026-08-22 22:05:11 +10:00
README.md docs: cover race mode in the README 2026-08-22 22:11:29 +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.
  • Create a group and you get a join code that never changes. 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 a 50/50 coin flip decided on the server; an odd number of movies gives one film a random bye. The owner reveals a round at a time and everyone else refreshes to follow along, so it builds up over a few minutes. Last film standing wins.

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

The race is 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 whole race, records every position at 60fps, and each browser replays that recording.

Only the seed is stored. The simulation is deterministic, so the seed and the ball order are the entire race, and it is rebuilt in a few milliseconds whenever someone opens it.

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.

Layout

main.go                 wires up pocketbase, hooks and routes
core/
  bracket.go            pairing and coin flips (no pocketbase dependency)
  plinko.go             the race physics, also pocketbase-free
  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/             custom endpoint calls and error formatting

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
tournament Format, status, the round waiting to be played, and the winner
matchup One pairing. Empty movieB is a bye; empty winner means unplayed

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.

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

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.