Building PlayMore - Architecture of a Tournament Management Platform
Published on May 31, 2026
PlayMore is a tournament management platform for Swiss youth football clubs. It handles PMF (Play More Football) multi-phase tournaments, knockout brackets, live scoring, and federation import β all from a single Docker container running PocketBase with a custom Go backend.
This post covers the architecture: why we chose this stack, how the pieces fit together, and what we learned building it.
Table of Contents
- The Stack
- System Architecture
- Why SPA, Not SSR
- Tournament Format System
- Self-Loading Store Architecture
- Auth and Security
- Go Backend
- Data Model
- Project Structure
- Deployment
- PWA
- Internationalization
The Stack
| Layer | Technology | Why |
|---|---|---|
| Frontend | SvelteKit 2 + Svelte 5 | Runes, small bundles, SPA-only |
| UI | shadcn-svelte (bits-ui v2) + Tailwind CSS 4 | Consistent components, utility CSS |
| Backend | PocketBase 0.37 (custom Go binary) | Auth, DB, real-time, file storage β one binary |
| Go Extensions | Six Go packages (clubroles, stats, invites, teams, signups, webauthn) | All server-side logic, typed and compiled |
| Forms | Superforms + Zod 4 | Declarative validation with PB error mapping |
| i18n | Paraglide JS 2 | Type-safe translations (DE/EN) |
| Animations | GSAP + Svelte transitions | Entrance animations, table reordering |
| Deployment | Docker (single container) | PocketBase serves SPA from pb_public |
The guiding principle: fewer moving parts. One process, one database, one container.
System Architecture
PocketBase serves both the API and the SvelteKit static files. When a browser hits the server, it gets the SPA from pb_public/. The SPA then talks to PocketBaseβs REST API and subscribes to real-time events β all on the same origin, no CORS issues.
Why SPA, Not SSR
PlayMore is a client-side SPA using adapter-static. There are no +page.server.ts files. All data loading happens client-side via PocketBaseβs JavaScript SDK.
This was a deliberate choice:
- Single container deployment β PocketBase serves the static files directly, no Node.js server needed
- Offline capability β the service worker caches the entire app shell
- Real-time first β all data flows through reactive stores with live subscriptions, not server-rendered pages
- Simpler hosting β any cheap VPS or NAS that runs Docker works
The tradeoff is no server-side rendering for SEO, but that does not matter here β this is an authenticated app for club members, not a content site.
Tournament Format System
PlayMore supports five tournament families, each with distinct rules:
PMF (Play More Football) β the Swiss youth development format where every team is guaranteed 8 games:
- G Juniors (ages 6-8): 2v2 and 3v3 games, field rotation focus
- F Juniors (ages 9-10): 3v3 and 4v4 games, game type balance
- E Juniors (ages 11-12): Two-phase system β Phase 1 is 3v3 with individual teams, Phase 2 combines teams into clubs for 6v6
Knockout β single elimination with first-round byes for non-power-of-2 team counts, and double elimination with a losers bracket.
Group + Knockout β a group stage (round-robin inside each group) feeds seeded teams into a knockout bracket. Placement matches (5th, 7th, 9th β¦) play between bracket rounds as a rest buffer. 2 or 4 groups, field-capped so no round exceeds the available field count.
Round Robin + Knockout β a single-group round-robin (Circle Method) followed by an optional flat knockout from the top finishers. Bracket sizes: none, top 2 (Final only), top 4 (SF + Final + optional 3rd), or top 8 (QF + SF + Final + optional 3rd). The post-RR standings seed the bracket, so seeding is determined by play, not pre-tournament estimates.
League β the long-running βseasonβ format. Double round-robin (every pair plays twice with home/away swapped), no knockout. Same wizard route as Round Robin + Knockout, branched on tournament.format: bracket locked to none, double-round-robin defaults on, scheduling defaults off (seasons run across weeks rather than a single tournament day).
The two newest formats share one generator (src/lib/utils/tournaments/round-robin-finals.ts) gated by a doubleRoundRobin flag and a bracket setting. Splitting them into separate generators is a follow-up trigger if league grows matchday-cadence scheduling or other season-specific concepts.
Every tournament follows the same lifecycle:
draft is metadata-only (name + date, no format picked yet). ready is generated-but-not-running β matches exist, but the clock hasnβt started. active is the live state. paused freezes the effective time so pause durations donβt push matches past their scheduled slots; resuming accumulates the pause into pause_offset_minutes.
The algorithm that generates PMF match schedules β priority-based pairing with exponential penalty curves, BYE-aware assignment, and field type balancing β is covered in The PMF Algorithm.
Self-Loading Store Architecture
State management is organized around self-loading tournament stores. Each tournament format has its own store class that loads data from PocketBase, subscribes to real-time updates, and exposes reactive state to components.
src/routes/(app)/tournaments/[id]/stores/
βββ BaseTournamentStore.ts # Interface all stores implement
βββ DraftTournamentStore.svelte.ts # Draft state (format = null or picked-but-not-started)
βββ PMFTournamentStore.svelte.ts # PMF Swiss system with club aggregation
βββ KnockoutTournamentStore.svelte.ts # Knockout brackets with elimination logic
βββ GroupKnockoutStore.svelte.ts # Extends KnockoutTournamentStore; adds group-phase logic
βββ tournament-context.svelte.ts # Svelte context setup
The layout creates the right store based on tournament status first, then format: if status === 'draft', it uses DraftTournamentStore (matches/standings donβt exist yet). Once a tournament transitions to ready / active, the layout reloads and instantiates the format-specific store. GroupKnockoutStore is a proper subclass of KnockoutTournamentStore β it overrides capabilities, standings, and bracketStructure but reuses watcher setup and team-record plumbing from the parent.
Components never need props drilled through β they call getTournamentContext() and get full reactive access.
The lifecycle in code:
// Layout detects format and creates the right store
const store = new PMFTournamentStore(tournamentId);
// Store loads its own data (tournament + matches + clubs in parallel)
await store.loadTournamentData(tournamentId);
// Set context for all child components
setTournamentContext(store);
// Any nested component:
const store = getTournamentContext();
// store.tournament, store.matches, store.standings -- all reactiveStandings are $derived from the matches array. When a match score changes via real-time subscription, standings recalculate automatically. There are zero $effect calls in any store file β all reactivity comes from $state, $derived, and explicit method calls.
Auth and Security
Three layers protect every page:
1. Two-Layer Auth Guard
Entering the authenticated (app) group passes through two gates. First, a load-function gate ((app)/+layout.ts) runs before the layout component mounts: it reads the localStorage token synchronously (pb.authStore.isValid) and redirect()s a logged-out visitor to /auth/login?redirect=β¦ before paying for the boot pipeline (setupAppContexts(), new AppBoot(), pwa.initialize()). Second, the <RequireAuth> component inside the (app) layout is the reactive half: it waits for server-confirmed auth state, catches revoked/expired tokens the optimistic check waved through, reacts to mid-session logout, and renders the βAuthenticatingβ¦β phase. Keep both. Authorization tiers above plain auth (requireSuperuser, requireVerified) stay per-page, mirroring the PocketBase API rules.
2. Club Roles (Go Backend)
The clubroles Go package provides 6 endpoints for server-side club management:
- Create role, add member, update role, delete role
- Create club-branded invite, accept invite
Business rules are enforced server-side: maximum 3 admins per club, and you cannot demote or remove yourself if you are the last admin. These checks cannot be bypassed regardless of what the frontend sends.
3. PocketBase API Rules
Collection-level API rules enforce authorization independently of the frontend. Even if a client crafts a direct API request, PocketBase rejects unauthorized operations based on the configured rules for each collection. For example, tournaments.createRule requires @request.auth.verified = true, so an unverified user canβt bypass the disabled βCreate tournamentβ button by calling the API directly.
Passkeys β alongside email/password, users can register and log in with WebAuthn platform authenticators (Touch ID, Windows Hello, device passkeys). Passkey credentials live in a dedicated passkeys collection; the login flow returns a full RecordAuthResponse so the auth store subscription fires the same way as a password login.
Structured App Boot
The root layout is deliberately thin β it provides only the auth context and the global theme (superuser-set, world-readable, so it applies to anonymous tournament viewers before sign-in). Everything heavier lives in the (app) layer. setupAppContexts() wires the authenticated-app store contexts (clubMembership, settings, network, pwa) in one place, and an AppBoot instance orchestrates loading: it runs the gating loads (settings, app-info, and β for non-superusers β club membership) in parallel via Promise.allSettled so one failure doesnβt block the others, and dedupes by user id so a re-mount or a double-firing auth callback canβt double-boot. While stores load, the layout shows <AppBootProgress />; once allStoresLoaded is true it renders the page.
Public Skeleton Layer
The route tree splits into an authenticated (app) layer (the brain β boot pipeline + full chrome) and a lightweight (public) skeleton for the landing, tutorial, and release-timeline pages. The public layer mounts no user-data stores and no boot gate β a logged-out visitor paints as soon as the (fast, global) theme resolves. Auth pages and the public tournament view /t/[id] live outside even this group and stay bare.
Go Backend
The backend is a custom Go binary that embeds PocketBase. There is no JavaScript hook layer anymore β every piece of server-side logic lives in a typed, compiled Go package registered from main.go:
clubroles.Register(app) // club roles, members, invites-accept
stats.Register(app) // tournament_results computation + match archival
invites.Register(app) // invite-only registration, email, cron cleanup
teams.Register(app) // user-owned + tournament-scoped team rules
signups.Register(app) // signup-request approval workflow
webauthn.Register(app) // passkey registration + login
Each module registers its own routes, record hooks, and (where needed) cron jobs. invites runs a daily 2 AM cleanup of expired tokens; stats runs a daily 3 AM archival job that deletes match records from tournaments completed more than a week ago, after persisting their final standings. SPA static files are still served from pb_public/ with an index.html fallback. Porting the old JSVM hooks to Go bought type safety, compile-time checks, and shared helpers β at the cost of a Go toolchain already present for the binary.
Historical Stats and Match Archival
Completed tournaments donβt keep their match records forever. A daily Go cron archives matches from tournaments finished over a week ago β but first computes per-team season results (placement, points, goal difference, W/D/L) into a tournament_results collection. The format stores fall back to persisted standings JSON (final_standings / group_standings on the tournament record) once the live matches are gone, so a long-finished tournament still renders its final table.
Theme System
Theming is a two-axis model. themeStyle (a data-theme attribute β structural CSS blocks: crisp, bold, editorial, mono, or the neutral default baseline) swaps shadows/radii/fonts; themeColor is a free brand-colour hex applied inline. Both persist on the world-readable app_info record (superuser-writable) and mirror to localStorage so the early-paint script applies them with no FOUC. Superusers edit it at /admin/theme.
Data Model
14 PocketBase collections (excluding system tables):
| Pattern | Collections | Purpose |
|---|---|---|
| Core | tournaments, matches, clubs, users, teams | Primary entities |
| Auth | club_roles, invites, passkeys | Club membership, invite tokens, WebAuthn credentials |
| Signup | tournament_signups | User-owned-team signup requests with organiser approval |
| Results | tournament_results | Per-season team results: season, team, tournament, placement, points, GD, W/D/L |
| Config | tournament_configs | Knockout admin overrides (defaults: json); PMF rows are dead-letter (now hardcoded) |
| App | app_info | version, enabled_formats (multi-select), skip_email_verification, theme, etc. |
| Views | club_members_view, users_public | Read-only projections for display |
Teams come in three flavours distinguished by which relations are set: Swiss (team.club set), user-owned (team.created_by set β βmy teamβ across tournaments), and tournament-scoped slot (team.tournament set β cascade-deleted with the tournament). The auto-stamping created_by is set by a PB hook on create, so a user canβt forge ownership.
The users_public view exposes only name and avatar without leaking emails or other sensitive fields. club_members_view joins club_roles with users for the member management UI.
Compared to KnowMoreβs 29 collections, PlayMore is intentionally smaller. A tournament app has a narrower domain: tournaments, matches, clubs, and the role system that ties them together.
Project Structure
src/
βββ lib/
β βββ animations/ # GSAP + Svelte transition utilities
β βββ auth/ # Auth store + context + schemas
β βββ components/ # Guards, UI (shadcn-svelte), tournaments
β βββ constants/ # Tournament format constants
β βββ pocketbase/ # PB client configuration
β βββ services/ # FootdataService, ClubSyncService
β βββ stores/ # ClubMembership, UserPermissions, ClubMembers
β βββ tournaments/ # Tournament adapters and type definitions
β βββ utils/ # batch-ops, watch, tournament algorithms
β βββ types/ # Generated PocketBase types
βββ routes/
β βββ (app)/ # Authenticated app: dashboard, tournaments, profile, admin
β βββ (public)/ # Public skeleton: landing, tutorial, release timeline
β βββ (auth)/ # Login, register, admin login
β βββ t/ # Public read-only tournament view /t/[id]
β βββ dev/ # Dev-only pages
β βββ showcase/ # Component showcase
messages/ # i18n: 10 domains (en + de)
pb/
βββ main.go # Custom PocketBase binary
βββ clubroles/ # Club roles, members, invite-accept
βββ stats/ # tournament_results compute + match archival
βββ invites/ # Invite-only registration, email, cron
βββ teams/ # User-owned + tournament-scoped team rules
βββ signups/ # Signup-request approval workflow
βββ webauthn/ # Passkey registration + login
βββ pb_migrations/ # Go collections snapshot
The routes/(app)/tournaments/[id]/ subtree contains the self-loading stores, format-specific components, and start wizards. /tournaments/create is intentionally metadata-only now β a five-field form (name, date, time, level, description) that produces a format = null draft. The format is picked in place on the detail page via a FormatPicker component; format-specific fields (category, clubType, host, canton, clubs) are committed with the format PATCH. Per-format creation wizards no longer exist.
Route groups keep guards layered at the layout level: (app) wraps everything in <RequireAuth>, (auth) is public, and (public) is the lightweight skeleton for landing/tutorial/timeline. There is no top-level (admin) group anymore β the superuser is an app user, so admin folded into (app)/admin/, whose own layout wraps <RequireRole role="superuser"> inside the authenticated layer. No page-level auth boilerplate.
Deployment
The production Dockerfile uses a three-stage multi-stage build:
- Node stage (
node:22-alpine) β builds the SvelteKit frontend withpnpm build, settingPUBLIC_POCKETBASE_URL=""so API calls go to the same origin - Go stage (
golang:1.25-alpine) β compiles the custom PocketBase binary with the six Go packages - Runtime stage (
alpine:latest) β copies the binary, static files, and migrations into a minimal image
The SvelteKit output goes into pb_public/, which PocketBase serves alongside the API. One port (8090), one process, one container.
EXPOSE 8090
CMD /pb/pocketbase serve --http=0.0.0.0:8090 --origins=${APP_DOMAIN}
The image includes a health check that pings /api/health every 30 seconds. Backups are simple: copy the SQLite file. The entire database lives on a single volume mount.
PWA
PlayMore runs as a Progressive Web App via @vite-pwa/sveltekit:
- Installable β add to home screen, launches like a native app
- Offline shell β service worker caches the app shell and static data
- Silent updates β new versions activate in the background on next navigation
- SSE passthrough β
navigateFallbackDenylist: [/\/api\//]prevents the service worker from intercepting PocketBase real-time connections
Runtime caching uses different strategies per data type: NetworkOnly for real-time SSE, NetworkFirst for dynamic tournament data (3-second timeout with 24-hour cache fallback), and CacheFirst for static reference data like clubs and configs.
Internationalization
Paraglide JS provides type-safe i18n with compile-time checks. Every translation key is a typed function:
import * as m from '$lib/paraglide/messages';
m.tournament_create(); // "Create Tournament" / "Turnier erstellen"
m.pmf_rounds_remaining(); // "Rounds remaining" / "Verbleibende Runden"Missing keys are caught at build time, not at runtime in production. The app supports German and English across 10 message domains (common, auth, tournaments, knockout, pmf, forms, admin, navigation, tutorial, errors), with Swiss football terminology (Junioren, Spielrunde) where it matters.
This is the first post in the PlayMore series. For a non-technical overview, see PlayMore - Tournament Management That Actually Works. For the real-time data system, see PlayMore Real-Time Stores. For the tournament algorithm, see The PMF Algorithm.