- Go 60.1%
- TypeScript 36.8%
- CSS 1.6%
- Makefile 1.1%
- HTML 0.3%
- Other 0.1%
|
|
||
|---|---|---|
| .forgejo | ||
| core | ||
| migrations | ||
| ui | ||
| utils | ||
| .env.example | ||
| .gitignore | ||
| .goreleaser.yaml | ||
| CONTRIBUTING.md | ||
| go.mod | ||
| go.sum | ||
| hooks_test.go | ||
| join_test.go | ||
| LICENSE | ||
| main.go | ||
| main_test.go | ||
| Makefile | ||
| PLAN.md | ||
| race_test.go | ||
| README.md | ||
| release.sh | ||
| tournament_test.go | ||
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 rewrittenencoding/jsonby default, which sends pocketbase v0.39.11'sCollection.UnmarshalJSONinto 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.modrequires 1.26.5, so that case cannot arise.) If you build with a barego buildon 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.