Building PlayMore - Architecture of a Tournament Management Platform

Published on Mar 9, 2026

Building PlayMore - Architecture of a Tournament Management Platform

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

LayerTechnologyWhy
FrontendSvelteKit 2 + Svelte 5Runes, small bundles, SPA-only
UIshadcn-svelte (bits-ui v2) + Tailwind CSS 4Consistent components, utility CSS
BackendPocketBase 0.36 (custom Go binary)Auth, DB, real-time, file storage β€” one binary
Go Extensionsclubroles packageServer-side club role CRUD, invite accept
JS HooksPB JSVMCron jobs, invite validation, user lifecycle
FormsSuperforms + Zod 4Declarative validation with PB error mapping
i18nParaglide JS 2Type-safe translations (DE/EN)
AnimationsGSAP + Svelte transitionsEntrance animations, table reordering
DeploymentDocker (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 reactive

Standings 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. Guard Components

Guard components replace repeated auth-checking boilerplate. <RequireAuth> wraps any content that needs an authenticated user:

<RequireAuth>
	{@render children()}
</RequireAuth>

It handles three states: loading (auth syncing), authenticated (render children), and unauthenticated (show error). <RequireRole> adds role checking for superuser or club admin access. <RequirePermission> takes an arbitrary boolean predicate for finer control.

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.

Go Backend

The backend is a custom Go binary that embeds PocketBase. main.go registers four things:

func main() {
    app := pocketbase.New()

    // 1. JavaScript hooks from pb_hooks/*.js
    jsvm.MustRegister(app, jsvm.Config{
        HooksDir: hooksDir,
    })

    // 2. Auto-migration support
    migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
        Dir: migrationsDir, Automigrate: true,
    })

    // 3. Club roles management (6 endpoints)
    clubroles.Register(app)

    // 4. SPA serving with index.html fallback
    app.OnServe().BindFunc(func(se *core.ServeEvent) error {
        se.Router.GET("/{path...}", apis.Static(os.DirFS(publicDir), true))
        return se.Next()
    })

    app.Start()
}

The clubroles package is a local Go module β€” not an external dependency. It registers its own routes and middleware with the PocketBase router. The Go handlers validate requests, enforce business rules, and call app.Save() directly.

Six JavaScript hook files handle the rest: invite token generation, email sending, user registration validation, custom API routes, and a daily cron job that cleans up expired invites at 2:00 AM. These run inside PocketBase’s JSVM, so they deploy as plain .js files alongside the binary.

The result is a single compiled binary with no runtime dependencies.

Data Model

12 PocketBase collections (excluding system tables):

PatternCollectionsPurpose
Coretournaments, matches, clubs, users, teamsPrimary entities
Authclub_roles, invites, passkeysClub membership, invite tokens, WebAuthn credentials
Signuptournament_signupsUser-owned-team signup requests with organiser approval
Configtournament_configsKnockout admin overrides (defaults: json); PMF rows are dead-letter (now hardcoded)
Appapp_infoversion, enabled_formats (multi-select), skip_email_verification, etc.
Viewsclub_members_view, users_publicRead-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/
β”‚   β”œβ”€β”€ auth/               # Login, register, admin login
β”‚   β”œβ”€β”€ admin/              # Users, invites, clubs management
β”‚   β”œβ”€β”€ profile/            # Profile, club setup, member management
β”‚   β”œβ”€β”€ tournaments/        # List, create (PMF/KO), [id] (view/edit/start)
β”‚   └── invite/             # Accept flow
messages/                   # i18n: 10 domains (en + de)
pb/
β”œβ”€β”€ main.go                 # Custom PocketBase binary
β”œβ”€β”€ clubroles/              # Go club role package (4 files)
β”œβ”€β”€ pb_hooks/               # JS hooks (6 files)
└── 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>, (admin) adds <RequireRole role="superuser">, (auth) is public. No page-level auth boilerplate.

Deployment

The production Dockerfile uses a three-stage multi-stage build:

  1. Node stage (node:22-alpine) β€” builds the SvelteKit frontend with pnpm build, setting PUBLIC_POCKETBASE_URL="" so API calls go to the same origin
  2. Go stage (golang:1.25-alpine) β€” compiles the custom PocketBase binary with the clubroles package
  3. Runtime stage (alpine:latest) β€” copies the binary, static files, migrations, and hooks 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.