π¬π§ English Β· π©πͺ Deutsch
Run a full beer pong tournament for up to 60 teams straight from your phone: automatic scheduling across as many tables as you have, a live dashboard your friends can follow on a projector, QR-code sign-up for players (no app, no account, no signup friction), and push notifications when it's your turn to play. Free, open source, and self-hostable.
π Try it live: beerpong.klaiber.dev - no installation needed, just scan a code and play. Want to run your own instance? See Docker below.
- Features
- Organizer Guide
- Participant Guide
- Tech Stack
- Project Structure
- Local Development
- Testing
- Environment Variables
- Internationalization
- Push Notifications (PWA)
- Production Build
- Docker
- Data Model
- Tournament Flow in Detail
- License
- Organizer accounts: organizers register with email + password and log in to create and manage tournaments; a session cookie keeps them signed in across visits. Players never need an account - they stay fully anonymous, identified only by a per-device token.
- Organizer flow: once logged in, create a tournament (name, location, info text, team size, max. teams, number of tables, format, group size, qualifiers/group, standard round time limit) - it immediately shows up in "My tournaments" on the home page.
- Access only via QR code or 8-character code (unambiguous characters, no 0/O/1/I). Participants scan the QR code, enter their name, and create a team or join an open one. Once a team reaches its configured size, it's "full" and disappears from the join list. With a team size of 1, teams are automatically called "Participants" instead of "Team".
- Two tournament formats:
- Group stage + knockout: round-robin group stage, then a knockout phase with the best teams (snake seeding - group winners face runners-up from other groups), including byes and an optional 3rd place match.
- Free bracket: no group stage - once registration closes, all teams are randomly seeded directly into the knockout bracket. Good for smaller or spontaneous tournaments.
- Standard round time limit: the organizer can set a time limit (in minutes) for group stage matches. Once a table runner taps "Start match", a countdown runs there; when it expires, a push notification and a red banner appear on the table view.
- Live table dispatcher: as soon as a table frees up, the next match that's ready to play is assigned to it automatically - no rigid printed schedule needed, works with any number of tables in parallel.
- Push notifications (installable PWA, works on Android and iOS): teams are automatically notified when they're called to a table or their round time runs out - even while the app is in the background.
- Multilingual (English/German): the interface automatically detects the browser/system language (default: English) and can be switched manually at any time via a toggle in the top right corner.
- Live dashboard (projector/TV friendly): current table occupancy, overall and group standings, knockout bracket - all live via WebSocket.
- Table view: a large, dedicated touch interface per table (with its own QR code to print and stick on) to start a match and enter the result.
- Admin dashboard: tournament control (open/close registration, start group/knockout stage, finish tournament), table management (add/disable/delete), team management (rename, remove players, delete team), quick result entry for all live matches, CSV export of the final standings.
- Multiple tournaments: any number of independent tournaments per organizer account, each with its own access code.
("Organizer"/"Spielleiter" - the person running the tournament.)
- Create an account. Open the app, click Register and sign up with an email and password. You're logged in immediately - no email confirmation step.
- Create a tournament. From the home page, click + Create new tournament. Fill in a name
and, optionally, a location and info text (rules, prizes, kickoff time - shown to participants).
Choose:
- Team size (1 = solo/individual mode - participants sign up alone instead of forming teams).
- Max. teams and number of tables.
- Format: Group stage + knockout (round-robin groups, then a bracket with the best teams - recommended from ~8 teams) or Free bracket (no groups, everyone goes straight into a randomly-seeded knockout bracket - good for smaller or spontaneous tournaments).
- For the group-stage format: teams per group, qualifiers per group, and an optional standard round time limit (minutes) for group matches.
- Share access. The admin dashboard shows a QR code and an 8-character access code. Share either however you like - print the QR code, message the code, or display it on a projector. Participants never need an account to join.
- Open registration. Click Open registration once you're ready for signups. Teams appear live in the admin dashboard as people join; you can remove a player or delete a team at any point if needed.
- Start the tournament. Once you have at least 2 teams, click Close registration & start group stage (or & draw knockout bracket for the free-bracket format). From here on, the live table dispatcher takes over: as soon as a table is free, the next ready match is assigned to it automatically.
- Let matches happen. Whoever's at a table taps Start match there to begin the timer (if you set one), then enters the final score. You can also enter results yourself for any match currently at a table, directly from Live matches in the admin dashboard - no need to walk around with a phone at every table.
- Advance to the knockout stage (group-stage format only). Once every group match is done, click Start knockout stage - the bracket is drawn automatically (snake-seeded by group standings, byes handled for you, plus an optional 3rd-place match).
- Finish up. After the final is played, click Finish tournament to crown the champion (confetti included π), then Export CSV to download the final standings.
- Manage tables anytime from the admin dashboard: add, disable, or delete tables, and print each table's own QR code so players can jump straight to that table's screen.
("Participant"/"Teilnehmer" - anyone signing up to play. No account needed, ever.)
- Join. Scan the tournament's QR code, or open the app and enter its 8-character access code on the home page.
- Sign up. Create a new team (try the π² button next to the team-name field for a random name suggestion) or join one that's still open. In solo-mode tournaments you just enter your own name - no team name needed. Once a team reaches its configured size, it's full and disappears from the join list for everyone else.
- Turn on notifications. Right after signing up, tap π Push notifications on your team page so you don't miss your call to a table. This works directly on Android; on iPhone, Safari only allows it after adding the site to your Home Screen (Share β Add to Home Screen) - the app detects this and shows you the exact steps instead of a button that wouldn't do anything.
- Wait for your match. Your team page always shows your next match. The moment a table opens up for you, you'll get a push notification - even if the app is in the background.
- Play. At the table, tap Start match to begin (and start the round timer, if the organizer set one), then enter the final score once you're done - a tie isn't possible in beer pong, so there must be a winner.
- Follow along live. Open the Live Board at any time (no login needed) to check the current bracket, standings, and which tables are occupied.
- Install the app (optional). A banner will offer to add BeerPong 3000 to your home screen for quicker access and reliable notifications - handy if you're playing multiple rounds.
- Server: Node.js, Express, Socket.IO, Prisma + SQLite, TypeScript,
web-push(VAPID) - Client: React, Vite, TypeScript, Tailwind CSS, React Router, socket.io-client, i18next/react-i18next, qrcode.react, a hand-rolled Service Worker (no framework)
beerpong-tournament/
βββ server/ Express + Socket.IO API, Prisma/SQLite
β βββ prisma/ Schema + migrations
β βββ src/
β βββ routes/ REST endpoints (tournaments, teams, matches, push)
β βββ services/ Scheduler (schedule/bracket), standings, push, codes
β βββ middleware/ Admin auth
β βββ sockets.ts WebSocket broadcast
βββ client/ React SPA + PWA
β βββ public/ manifest.webmanifest, sw.js (service worker), icons
β βββ src/
β βββ i18n/ Translation resources (en.json, de.json) + language setup
β βββ pages/ Home, Create, Join, ParticipantHub, AdminDashboard,
β β TableView, LiveDashboard
β βββ components/ UI building blocks (cards, buttons, MatchCard, timer, ...)
β βββ lib/ API client, socket hook, push helper, storage
βββ .github/workflows/ CI/CD (Docker build + deploy)
Requirement: Node.js β₯ 20.
npm install
npm run prisma:migrate # creates server/dev.db and applies all migrations
npm run dev # starts server (port 4000) and client (port 5173) in parallelThen open http://localhost:5173 (the Vite dev server automatically proxies /api and
/socket.io to the server on port 4000).
An end-to-end Playwright suite in e2e/ drives the real app in a browser against an isolated
SQLite database (server/prisma/test.db, never touching your dev data) - full tournament
lifecycles (both formats, byes, the live table dispatcher, standings tiebreaks, the visual
bracket), i18n/locale detection, the PWA install prompt, and admin/real-time sync.
npx playwright install --with-deps chromium # once
npm run test:e2e # headless
npm run test:e2e:headed # watch it runAll variables live in server/.env (see server/.env.example):
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | SQLite path, e.g. file:./dev.db |
PORT |
no | Server port, defaults to 4000 |
VAPID_PUBLIC_KEY |
no* | Public key for Web Push |
VAPID_PRIVATE_KEY |
no* | Private key for Web Push (keep secret!) |
VAPID_SUBJECT |
no | mailto: address identifying the push sender |
CORS_ALLOWED_ORIGINS |
no | Comma-separated extra origins. Empty (default) = same-origin only |
RATE_LIMIT_API_MAX |
no | API requests per minute per IP, defaults to 1200 |
RATE_LIMIT_LOGIN_MAX |
no | Failed logins per account per 15 min, defaults to 10 |
RATE_LIMIT_LOGIN_IP_MAX |
no | Failed logins per IP per 15 min, defaults to 100 |
RATE_LIMIT_REGISTER_MAX |
no | New accounts per IP per hour, defaults to 20 |
* Without VAPID keys the app still runs normally, only push notifications are disabled (the server logs a warning). Generate your own keys:
npx web-push generate-vapid-keysThe interface is fully available in English and German (client/src/i18n/en.json and
de.json). On first load the app automatically detects the browser/system language; if it isn't
German, English is used as the default. Anyone can switch the language manually at any time via
the toggle in the top-right corner of the header (the choice is remembered locally).
Server-generated content (tournament/team/player names, table names, group and round labels like "Semifinal") is deliberately not translated - just like any other user-generated content. API error messages are transmitted as stable error codes and displayed in the client in the currently selected language.
To add another language: create a new resource file following the pattern of en.json in
client/src/i18n/, register it under resources in client/src/i18n/index.ts, and add it to
supportedLngs.
The app is a fully-fledged Progressive Web App with its own service worker
(client/public/sw.js) and web app manifest (client/public/manifest.webmanifest):
- Android (Chrome/Edge): push works directly in the browser tab; installing to the home screen is optional (but recommended for the full app look and feel).
- iOS (Safari): Apple only allows web push for pages that have been added to the home screen (Share β "Add to Home Screen"). The app detects this automatically and shows iPhone users the right instructions instead of a button that wouldn't do anything.
Notifications sent:
- "You're up!" - as soon as the live table dispatcher assigns a team to a free table.
- "Time's up!" - as soon as the organizer's standard round time limit is reached for a running group stage match (checked server-side every 15 seconds).
Participants enable push via the "π Push notifications" card on their personal team page, right after signing up.
npm run build
node server/dist/index.js # serves the built React app and the API togetherdocker build -t beerpong-3000 .
docker run -p 8080:80 \
-e VAPID_PUBLIC_KEY=... -e VAPID_PRIVATE_KEY=... -e VAPID_SUBJECT=mailto:you@example.com \
-v beerpong-data:/data \
beerpong-3000The image is a multi-stage build: the server and client are built, and the final image only runs
the Node server (port 80), which also serves the static React app. The SQLite database lives at
/data/dev.db (mount it as a volume, otherwise data is lost when the container restarts).
Prisma schema (server/prisma/schema.prisma):
- User / Session - organizer accounts (email + bcrypt password hash) and their login sessions (DB-backed, so logging out actually invalidates the session)
- Tournament - tournament metadata, format, time limit, access code, owning organizer
(
organizerId) - Table - tables (free/in use/disabled) plus a secret token that is embedded in the table's QR code and authorises entering results at that table
- Team / Player - teams and their members (a player token identifies the device)
- Group - group stage groups (only for the "group stage" format)
- Match - match pairings including knockout bracket chaining (
nextMatchId,loserNextMatchIdfor the 3rd place match) - PushSubscription - web push subscriptions per player/device
- The organizer logs in, creates the tournament (status
DRAFT), and opens registration (REGISTRATION). - Participants join via QR code/access code, create or complete teams.
- The organizer closes registration:
- Group stage: teams are randomly distributed into groups, a round-robin schedule is
generated, status becomes
GROUP_STAGE. - Free bracket: teams are randomly seeded directly into a knockout bracket, status jumps
straight to
KNOCKOUT.
- Group stage: teams are randomly distributed into groups, a round-robin schedule is
generated, status becomes
- The live dispatcher continuously assigns free tables to the next matches ready to play; teams are notified via push.
- Once the group stage is complete, the organizer generates the knockout stage (snake seeding by group standings, including byes and a 3rd place match).
- After the final, the organizer finishes the tournament (
FINISHED); results can be exported as CSV.
MIT - do whatever you want with it, attribution appreciated but not required.