A double-elimination tournament bracket creator and tracker for Android, built with Expo (TypeScript).
npm install
npx expo startThen press a to open the Android emulator, or scan the QR code with Expo Go.
npm test # jest — bracket engine behaviour
npm run typecheckThe bracket engine's failure mode is silent: a bug in bye propagation can
auto-resolve a real match, eliminating a player who never got to play, without
anything throwing. utils/__tests__/bracketEngine.test.ts guards against this by
playing thousands of randomised tournaments (every field size from 3 to 32, with
seeded RNG so failures reproduce) and asserting end-state invariants:
- exactly one champion, holding at most one loss
- placements form a contiguous
1..N - everyone below the top two leaves with exactly two losses and an
eliminatedflag - no match is flagged a bye while holding two real participants
- every W/L record matches the completed match list
Run the suite after any change to bracketGenerator.ts or doubleElimLogic.ts.
Releases are built by GitHub Actions on any v* tag and published to GitHub
Releases. The self-hosted F-Droid repo at https://lbellows.github.io/fdroid/
picks them up from there, and Obtainium can track the Releases page directly.
See FDROID.md for the full procedure.
Each release carries one APK per CPU architecture rather than a single universal
one — download arm64-v8a for any phone made in roughly the last decade,
armeabi-v7a for an older 32-bit device, x86_64 for an emulator.
The native android/ project is committed and generated from app.json:
npm run prebuild # regenerate android/ after changing app.json
npm run prebuild:check # verify android/ and the changelog match the configBuilding locally needs JDK 17, the Android SDK, and the NDK — react-native-reanimated,
react-native-worklets and react-native-screens compile C++ from source under the
new architecture. CI provides all of it, so tagging a release is the easier path than
setting up a local toolchain.
The app declares no Android permissions. The five that Expo adds by default
(INTERNET, SYSTEM_ALERT_WINDOW, READ_EXTERNAL_STORAGE,
WRITE_EXTERNAL_STORAGE, VIBRATE) are stripped via android.blockedPermissions
in app.json, because nothing in the app uses them.
plugins/withoutUpdatesMetadata.js additionally strips the expo.modules.updates.*
manifest entries that the Expo template emits unconditionally. expo-updates is not
a dependency, so the app has no over-the-air update mechanism and the manifest should
not imply one.
The cost is that a locally built dev APK cannot reach the Metro bundler without
INTERNET. Develop with npx expo start and Expo Go, or npm run web. See the
development caveat in FDROID.md if you need a Metro-connected build.
app/ expo-router screens
_layout.tsx root stack navigator
index.tsx Home: list of tournaments
new-tournament.tsx Create tournament + add participants
tournament/[id]/
index.tsx Bracket view
results.tsx Final standings + match history
components/
BracketView.tsx Horizontal scrollable bracket (two swimlanes)
MatchCard.tsx Individual match card with participant names/scores
MatchDetailSheet.tsx Bottom-sheet modal for entering scores
ParticipantRow.tsx Row used in New Tournament and Results screens
hooks/
useTournament.ts Loads/saves a single tournament from AsyncStorage;
exposes recordResult which applies doubleElimLogic
useBracketEngine.ts Pure derived state (grouping, counts, champion) for UI
utils/
bracketGenerator.ts Generates the full bracket match graph for N players
doubleElimLogic.ts Stateless result-recording and bracket advancement
exportUtils.ts Markdown export via expo-sharing / clipboard
types/
tournament.ts All TypeScript interfaces (Tournament, Match, Participant…)
-
Power-of-2 padding — N players are rounded up to the next power of 2 (P). Extra slots become byes (null participants). Top seeds receive byes.
-
Seeding — Uses the standard recursive bracket-position algorithm:
positions(P) = positions(P/2).flatMap(s => [s, P+1-s])producing matchups like 1v8, 4v5, 2v7, 3v6 for P=8. -
Winners Bracket — Standard single-elimination tree. Each match points to the next match for its winner via
winnerNextMatchId. -
Losers Bracket — Alternating rounds:
- Consolidation (odd LB rounds): previous LB survivors play each other.
- Drop-in (even LB rounds): LB survivors face incoming WB losers. LB R1 cross-pairs WB R1 losers to avoid early rematches. LB R2k receives WB round k+1 losers.
-
Grand Final — Pre-creates two match slots:
- Round 1: WB champion vs LB champion.
- Round 2 (reset): played only if the LB champion wins Round 1 (both finalists then have one loss each).
-
Bye propagation — After the graph is wired,
propagateByesiteratively:- Completes WB R1 bye matches immediately.
- Cascades winners into the next-match slots.
- Auto-completes any downstream match whose unfilled slot will never receive a participant (its feeder was itself a bye or void).
recordResult(tournament, matchId, winnerId, p1Score, p2Score) returns a new Tournament (immutable update):
- Updates match status/scores/winner.
- Increments participant win/loss counters.
- Places the winner in the next WB/LB match slot.
- Places the loser in the LB (or marks them eliminated if they were already in LB).
- Re-runs bye propagation to resolve any newly deterministic slots.
- Handles the Grand Final reset branch.
- Assigns placements (1st, 2nd, 3rd…) on tournament completion.
All tournaments are stored as a JSON array in AsyncStorage under the key @bracketup/tournaments. Auto-saved after every result via useTournament.recordResult.
The Export button (top-right on the Bracket screen, and at the foot of the Results screen) produces a Markdown summary — final standings plus full match history.
- Native — written to the app cache directory, then handed to the OS share sheet.
- Web — copied to the clipboard, falling back to a
.mdfile download when the Clipboard API is unavailable.
Place icon.png (1024×1024), splash.png (1284×2778), adaptive-icon.png (1024×1024), and favicon.png (48×48) in the assets/ folder before building.
For local development npx expo start works without them.