PlayMore Real-Time Stores and Data Loading

Published on Mar 9, 2026

PlayMore Real-Time Stores and Data Loading

When a coach enters a match score on their phone, every spectator’s league table should update within milliseconds. When a tournament with 20 teams starts, 80 matches need to be created atomically. When a tournament resets to draft, all those matches must be deleted in one operation.

This post covers how PlayMore handles data loading, real-time updates, and state management — the self-loading store pattern, batch operations, and the watch utility that ties it all together.

Table of Contents

Self-Loading Tournament Stores

The core data pattern in PlayMore is the self-loading tournament store. Each tournament format has its own store class that manages its entire data lifecycle: fetching, subscribing, computing derived state, and cleaning up.

Every store implements the BaseTournamentStore interface:

export interface BaseTournamentStore {
	// Reactive state
	tournament: TournamentsResponse | undefined;
	clubs: ClubsResponse[];
	matches: MatchesResponse[];
	isLoading: boolean;
	error: string | null;

	// Computed getters
	readonly standings: StandingRecord[];
	readonly matchesByRound: RoundData[];
	readonly activeRound: number;
	readonly bracketStructure: BracketStructure | null;

	// Lifecycle
	loadTournamentData(tournamentId: string): Promise<void>;
	destroy(): void;
}

The layout selects the right store based on tournament status first, then format. Draft tournaments (no format picked, or matches not yet generated) use a dedicated DraftTournamentStore that returns empty arrays for matches/standings/bracket and subscribes to the tournament_signups collection instead:

function createTournamentStore(
	tournamentId: string,
	tournament: TournamentsResponse
): BaseTournamentStore {
	if (tournament.status === 'draft') {
		return new DraftTournamentStore(tournamentId);
	}
	switch (tournament.format) {
		case 'PMF':
			return new PMFTournamentStore(tournamentId);
		case 'KNOCKOUT_SINGLE':
		case 'KNOCKOUT_DOUBLE':
			return new KnockoutTournamentStore(tournamentId);
		case 'GROUP_KNOCKOUT':
		case 'ROUND_ROBIN_FINALS':
		case 'LEAGUE':
			// RR+Finals and LEAGUE are structurally single-group group-knockout
			// shapes for the read path -- standings + (optional) bracket render
			// the same way. A dedicated store splits off if the runtime needs
			// diverge (e.g. matchday-cadence scheduling for League).
			return new GroupKnockoutStore(tournamentId);
		default:
			throw new Error(`Unsupported format: ${tournament.format}`);
	}
}

const store = createTournamentStore(tournamentId, data.tournament);
setTournamentContext(store);

GroupKnockoutStore is a real subclass of KnockoutTournamentStore (extracted in commit d3e81fb) with override on capabilities, standings, bracketStructure, and updateMatch(). Inheritance avoids re-implementing the watcher setup and team-record plumbing; the group-phase logic lives only in the subclass. When status transitions draft → ready, the start wizard triggers a full layout reload so the factory runs again and swaps DraftTournamentStore out for the format-specific one — no in-place store swap, no stale subscription race.

Components never receive tournament data as props. They call getTournamentContext() and get full reactive access to the store:

const store = getTournamentContext();
// store.tournament, store.matches, store.standings -- all reactive

The context uses a Symbol key to prevent collisions:

const TOURNAMENT_CONTEXT_KEY = Symbol('tournament-context');

export function setTournamentContext(store: BaseTournamentStore): void {
	setContext(TOURNAMENT_CONTEXT_KEY, store);
}

export function getTournamentContext(): BaseTournamentStore {
	return getContext(TOURNAMENT_CONTEXT_KEY) as BaseTournamentStore;
}

Real-Time Subscriptions

Both store classes set up two watchers in their constructor — one for the tournament record and one for its matches:

constructor(tournamentId: string) {
  this.#tournamentWatcher = watchRecord(pb, 'tournaments', tournamentId, {
    expand: 'user,host,clubs',
    onData: (record) => {
      this.tournament = record;
      this.clubs = record.expand?.clubs || [];
    }
  });

  this.#matchWatcher = watchCollection(pb, 'matches', {
    filter: `tournament="${tournamentId}"`,
    sort: 'round,field',
    accept: (record) => record.tournament === tournamentId,
    onData: (records) => {
      this.matches = records;
    },
    onEvent: (action, record) => {
      if (action === 'update' && record.completed) {
        toast.success(`${record.team_a} - ${record.team_b} ${record.score_a}:${record.score_b}`);
      }
    }
  });

  this.loadTournamentData(tournamentId);
}

loadTournamentData() initializes both watchers in parallel:

async loadTournamentData(tournamentId: string): Promise<void> {
  this.isLoading = true;
  await Promise.all([
    this.#tournamentWatcher.initialize(),
    this.#matchWatcher.initialize()
  ]);
  this.isLoading = false;
}

After initialization, real-time events from PocketBase SSE flow into the store automatically. The local $state arrays update in place — no refetch needed. Because standings are computed via getters that read from this.matches, they recalculate automatically when any match changes.

The onEvent callback is separate from the data flow. It fires after the array update and handles side effects like toast notifications. This keeps the data pipeline pure and the side effects explicit.

The watch() Utility

The watch.ts module provides two functions that handle the subscription lifecycle while the caller manages reactive state via callbacks.

watchRecord fetches a single record by ID and subscribes to its changes:

export function watchRecord<T>(
	pb: PocketBase,
	collectionName: string,
	recordId: string,
	options: WatchRecordOptions<T>
): Watcher {
	let unsubscribe: UnsubscribeFunc | null = null;

	async function initialize(): Promise<void> {
		cleanup();
		const record = await pb.collection(collectionName).getOne<T>(recordId, fetchOptions);
		options.onData(record);

		unsubscribe = await pb.collection(collectionName).subscribe<T>(
			recordId,
			(e) => {
				if (e.action === 'update') options.onData({ ...e.record });
				if (e.action === 'delete') options.onDelete?.();
			},
			fetchOptions
		);
	}

	return { initialize, cleanup, refetch };
}

watchCollection fetches all matching records and subscribes with automatic CRUD array operations:

export function watchCollection<T>(
	pb: PocketBase,
	collectionName: string,
	options: WatchCollectionOptions<T>
): Watcher {
	let currentData: T[] = [];

	async function initialize(): Promise<void> {
		cleanup();
		const records = await pb.collection(collectionName).getFullList<T>(listOptions);
		currentData = records;
		options.onData(records);

		unsubscribe = await pb.collection(collectionName).subscribe<T>('*', (e) => {
			if (options.accept && !options.accept(e.record)) return;

			switch (e.action) {
				case 'create':
					currentData = [...currentData, e.record];
					break;
				case 'update':
					currentData = currentData.map((item) => (item.id === e.record.id ? e.record : item));
					break;
				case 'delete':
					currentData = currentData.filter((item) => item.id !== e.record.id);
					break;
			}

			options.onData(currentData);
			options.onEvent?.(e.action, e.record);
		});
	}

	return { initialize, cleanup, refetch };
}

The accept guard provides client-side filtering as a defensive check. PocketBase filters the subscription server-side, but the guard catches edge cases — like a realtime event arriving for a match that belongs to a different tournament.

Both functions return a Watcher interface with three methods: initialize() (fetch + subscribe), cleanup() (unsubscribe), and refetch() (re-fetch without resetting the subscription). The layout calls destroy() on unmount, which calls cleanup() on both watchers.

Batch Operations

Tournament lifecycle operations touch many records at once. A 20-team PMF tournament generates around 80 matches. PocketBase’s batch API has a maxRequests limit (default 50), so we chunk operations:

export const BATCH_CHUNK_SIZE = 40;

export async function sendInChunks(
	buildOperations: (batch, startIndex, endIndex) => void,
	totalOperations: number
): Promise<void> {
	for (let start = 0; start < totalOperations; start += BATCH_CHUNK_SIZE) {
		const end = Math.min(start + BATCH_CHUNK_SIZE, totalOperations);
		const batch = pb.createBatch();
		buildOperations(batch, start, end);
		await batch.send();
	}
}

Five batch functions cover the tournament lifecycle:

FunctionOperationsUse Case
createTournamentMatches()Create 30-80 match recordsStarting a tournament
resetTournamentToDraft()Delete all matches, reset statusUndoing a start
cancelTournamentPermanently()Delete matches, set cancelledPermanent cancellation
clearAllMatchResults()Reset scores, keep matchesRestarting mid-tournament
updateAllMatchesInRound()Update scores for a roundBulk score entry

clearAllMatchResults() has format-aware logic. For knockout tournaments, it preserves first-round team assignments and BYE matches, then re-advances BYE winners into round 2. For PMF, it simply clears all scores and results.

PMF Store: Standings Calculation

PMF standings are computed via a getter that reads from the reactive matches array. When any match changes via real-time subscription, the getter re-evaluates automatically.

The points system is standard football: 3 for a win, 1 for a draw, 0 for a loss. Tiebreakers resolve in order: points, goal difference, goals for, then alphabetical.

get standings(): StandingRecord[] {
  if (!this.matches.length || !this.tournament?.teams) return [];

  if (this.isPMFEJuniors) return this.calculateClubStandings();
  return this.calculateTeamStandings();
}

For G and F Juniors, standings are per-team. The store initializes a stats map from the tournament’s teams JSON, processes completed matches, and sorts by the tiebreaker chain:

const finalStandings = Array.from(stats.values())
	.sort((a, b) => {
		if (a.points !== b.points) return b.points - a.points;
		if (a.goalDifference !== b.goalDifference) return b.goalDifference - a.goalDifference;
		if (a.goalsFor !== b.goalsFor) return b.goalsFor - a.goalsFor;
		return a.team.localeCompare(b.team);
	})
	.map((team, index) => ({ ...team, position: index + 1 }));

E Juniors are different. Phase 2 aggregates individual teams into clubs: “FC Toss 1” and “FC Toss 2” become “FC Toss”. The store has a two-tier name lookup in getTeamClubId():

  1. Exact team name match against the teams JSON
  2. Club name match (for Phase 2 aggregation)
  3. Pattern match for numbered teams (/^ClubName\s+\d+$/)

This handles the transition where matches reference individual team names but standings need club-level aggregation.

Knockout Store: Bracket State

The knockout store adds bracket-specific logic on top of the same watch pattern. Key differences from the PMF store:

Winner advancement uses relational fields. Each match has source_match_a, source_match_b, next_match_winner, and next_match_loser (double elimination only). When a match completes, the winner’s name propagates forward:

async #advanceInSingleElimination(completedMatch, winner) {
  const winningTeam = winner === 'team_a' ? completedMatch.team_a : completedMatch.team_b;

  if (completedMatch.next_match_winner) {
    const nextMatch = this.matches.find(m => m.id === completedMatch.next_match_winner);
    if (nextMatch) {
      const updateData: any = {};
      if (nextMatch.source_match_a === completedMatch.id) updateData.team_a = winningTeam;
      if (nextMatch.source_match_b === completedMatch.id) updateData.team_b = winningTeam;
      await pb.collection('matches').update(nextMatch.id, updateData);
    }
  }
}

Double elimination adds loser tracking. Winners advance in the winners bracket. Losers from the winners bracket drop to the losers bracket via next_match_loser. Losers in the losers bracket are eliminated entirely.

BYE matches are pre-completed during generation with team_b: 'BYE'. They do not block tournament completion. The clearAllMatchResults() function preserves their completed state and re-advances BYE winners when resetting.

Placeholder names show “Match 3 Winner” or “Match 5 Loser” until the source match completes. The store caches these calculations in a Map that clears on every data change.

Global Stores

Three non-tournament stores handle user-level state. They demonstrate the range from real-time subscriptions to one-shot fetch patterns:

ClubMembershipStore uses watchCollection() to subscribe to the current user’s club_roles record. When a club admin changes your role or team assignment, the store updates in real-time. It exposes derived getters like isClubAdmin, canInvite, and canImportFederation.

UserPermissions is a pure logic class — no runes, no subscriptions. It takes a ClubMembershipStore as a data source and derives permissions: isClubAdmin(), canManageClub(), canInviteUsers(), canImportTournament(). Separating permission logic from data loading keeps both classes focused.

ClubMembersStore uses a cache-backed, search-on-demand pattern. It loads admins (max 3) and the current user on init, then fetches additional members only when the user types 3+ characters in a search field. Results are cached in a Map keyed by user ID. This avoids loading all club members upfront — a club with 50 members does not need to fetch all records on page load.

Federation Data: A Different Pattern

Not all data comes from PocketBase. The FootdataService calls an external API (the Swiss football federation matchcenter) for team schedules, and ClubSyncService syncs club data from that API into PocketBase.

These services use plain fetch calls with no real-time subscriptions. The data is read-only and does not change during a user session. This is intentionally different from the watch-based store pattern — each data source uses the pattern that fits it best.

Reactivity Without Effects

A deliberate architectural choice: there are zero $effect calls in any store file.

  • $state holds raw data arrays (matches, clubs, tournament record)
  • Getters compute derived values (standings, rounds, brackets, active round)
  • Explicit method calls handle side effects (loadTournamentData(), updateMatch(), destroy())

When a match score arrives via SSE, the watchCollection handler updates this.matches. The standings getter reads this.matches and returns a sorted array. The activeRound getter reads the same array and returns the first incomplete round number. No effect chains, no cascading updates, no timing issues.

This avoids the common Svelte 5 pitfall of cascading effects where one effect triggers another. The data flows in one direction: PocketBase SSE to $state to computed getters to UI.


This is the second post in the PlayMore series. For the architecture overview, see Building PlayMore. For a non-technical overview, see PlayMore - Tournament Management That Actually Works. For the tournament algorithm, see The PMF Algorithm.