Sports Ladder & Club Management Platform
A fully interactive mockup of PlayRise — tap screens, enter scores, switch tabs, send challenges. Use the navigation buttons or tap directly inside the phone.
PlayRise is a mobile-first sports management platform that brings automated ladder rankings, private challenges, and club administration to racket sports clubs.
Traditional sports ladders are managed manually — spreadsheets, whiteboards, or disconnected apps. PlayRise automates the entire process: when a player joins a ladder, rounds are generated automatically; when a match result is submitted, rankings update instantly, positions swap, and the next round is ready — with zero intervention from the club owner.
The system automatically pairs players by rank, updates wins/losses/points after each match, swaps positions when a lower-ranked player wins, and generates the next round — all in a single atomic database transaction.
Players within the same club can challenge each other to friendly matches regardless of ladder enrollment. Challenges have a 7-day expiry, can include a message and proposed date, and feed results back into the ladder system.
Club owners get a dedicated management interface to create and manage ladders, oversee members, run tournaments, configure club settings, and share invite codes — all from one dashboard.
Each sport has a dedicated color theme and emoji identifier used consistently across the UI for quick recognition.
playrise:// for invite linksPlayRise has two distinct user types, each with a completely separate navigation experience, dashboard, and set of capabilities.
User type is determined at signup and stored in the profiles.is_club_owner boolean column. This flag is set from the signup form where users self-identify, and is passed through Supabase auth metadata to the auto-created profile record.
isClubOwner from the auth context. Players are routed to /(tabs) (My Ladders, My Matches, Browse Clubs, Profile). Club Owners are routed to /(owner-tabs) (Members, Ladders, Tournaments, Club Settings). First-time owners are routed to the onboarding wizard at /onboarding/club.
| Role | Description | Permissions |
|---|---|---|
| admin | Club creator or promoted member | Full control — edit club, manage members, create/delete ladders |
| member | Regular club participant | Join ladders, challenge other members, submit match results |
PlayRise uses a modern, production-ready stack with minimal infrastructure overhead — the backend is fully managed by Supabase.
| Technology | Version | Purpose |
|---|---|---|
| React Native | 0.83.2 | Cross-platform mobile UI framework |
| Expo | SDK 55 | Build toolchain, native module access, OTA updates |
| React | 19.2.0 | Component model, hooks, state management |
| TypeScript | 5.9.2 | Type safety throughout the codebase |
| Expo Router | v4 | File-based navigation (like Next.js for mobile) |
| @expo/vector-icons | — | Ionicons icon set |
| expo-location | — | GPS for club distance sorting |
| expo-secure-store | — | Encrypted token storage |
| Service | Purpose |
|---|---|
| PostgreSQL 15 | Primary database — all app data |
| Supabase Auth | User registration, login, session management, JWT tokens |
| Row Level Security | Database-level access control per user/role |
| PostgREST (auto REST API) | Auto-generated REST API from database schema |
| Realtime (optional) | WebSocket subscriptions for live updates |
| Storage (optional) | Avatar and logo image uploads |
Certain actions — like updating another player's ranking after a match — are blocked by Row Level Security. These are handled by PostgreSQL functions tagged SECURITY DEFINER, which execute with the privileges of the function owner (superuser), bypassing RLS safely. This pattern is used for record_match_result() and generate_ladder_round().
Navigation is defined by the file system under app/. Route groups like (tabs) and (owner-tabs) create separate tab bars for each user type. Stack screens under app/club/, app/ladder/, etc. are shared and accessible from both roles.
The database schema is managed through versioned SQL migration files under supabase/migrations/. Each file is idempotent and applies incremental changes — new tables, columns, functions, or fixes — in chronological order.
The app is built around Expo Router's file-based routing system, with separate tab groups for each user type.
app/
├── _layout.tsx ← Root: checks auth → routes to correct group
│
├── (auth)/ ← Unauthenticated users
│ ├── login.tsx
│ └── signup.tsx
│
├── onboarding/
│ └── club.tsx ← 6-step wizard, first-time club owners only
│
├── (tabs)/ ← Players (bottom tab bar)
│ ├── _layout.tsx Tabs: My Ladders | My Matches | Browse Clubs | Profile
│ ├── index.tsx My Ladders
│ ├── challenges.tsx My Matches (Ladder / Private / History)
│ ├── clubs.tsx Browse Clubs
│ └── profile.tsx Player Profile
│
├── (owner-tabs)/ ← Club Owners (separate bottom tab bar)
│ ├── _layout.tsx Tabs: Members | Ladders | Tournaments | Club
│ ├── index.tsx Members Management
│ ├── ladders.tsx Ladders Management
│ ├── tournaments.tsx Tournaments
│ └── club.tsx Club Settings
│
├── club/[id].tsx ← Shared stack screens
├── club/create.tsx
├── club/manage.tsx
├── ladder/[id].tsx
├── ladder/create.tsx
├── ladder/manage.tsx
├── challenge/new.tsx
└── match/new.tsx
The root _layout.tsx manages routing decisions using three pieces of state from AuthContext:
/(auth)/onboarding/club wizard/(owner-tabs) with Members, Ladders, Tournaments, Club tabs/(tabs) with My Ladders, My Matches, Browse Clubs, Profile tabs| Directory | Contents |
|---|---|
app/ | All screens and navigation layouts (Expo Router) |
contexts/ | AuthContext — global auth state and user profile |
lib/ | Supabase client config, TypeScript type definitions |
constants/ | Color palette, sport colors and emoji mappings |
components/ | Shared components (currently minimal — styles inline) |
supabase/migrations/ | Versioned SQL schema files |
assets/ | App icons, splash images |
PlayRise uses 8 PostgreSQL tables plus 1 view. All tables have Row Level Security enabled.
auth.users (Supabase)
│
└─ profiles (1:1)
│
├─ club_members (N) ─── clubs
│ │
│ ├─ ladders (N)
│ │ │
│ │ └─ ladder_entries (N) ← position, wins, losses, points
│ │ │
│ │ └─ matches (N) ← winner_id, score, played_at
│ │
│ └─ tournaments (N)
│ │
│ └─ tournament_entries (N)
│
└─ challenges (N) ─── ladders
| Column | Type | Description |
|---|---|---|
| id | UUID PK | References auth.users.id |
| username | TEXT UNIQUE | Public handle (e.g. @alex_smith) |
| full_name | TEXT | Display name |
| avatar_url | TEXT | Profile picture URL |
| phone | TEXT | Optional phone number |
| is_club_owner | BOOLEAN | Determines navigation and UI role |
| onboarding_completed | BOOLEAN | Whether owner wizard has been completed |
| created_at | TIMESTAMPTZ | Account creation timestamp |
| Column | Type | Description |
|---|---|---|
| id | UUID PK | Auto-generated club ID |
| name | TEXT NOT NULL | Club display name |
| description | TEXT | Optional club bio |
| location | TEXT | Address or city |
| logo_url | TEXT | Club logo image URL |
| latitude / longitude | FLOAT8 | GPS coordinates for distance sorting |
| created_by | UUID FK | Owner's profile ID |
| invite_code | TEXT UNIQUE | 8-char code for sharing club membership |
| created_at | TIMESTAMPTZ | Creation timestamp |
| Column | Type | Description |
|---|---|---|
| id | UUID PK | Ladder ID |
| club_id | UUID FK | Parent club |
| sport | ENUM | tennis | padel | squash |
| name | TEXT NOT NULL | Ladder display name |
| is_active | BOOLEAN | Toggleable by owner — inactive ladders are read-only |
| season_start / season_end | DATE | Optional season date range for display |
| created_at | TIMESTAMPTZ | Creation timestamp |
| Column | Type | Description |
|---|---|---|
| id | UUID PK | Entry ID |
| ladder_id | UUID FK | Parent ladder |
| user_id | UUID FK | Player's profile ID |
| position | INTEGER NOT NULL | Current rank (1 = best). UNIQUE per ladder. |
| wins | INTEGER | Total wins in this ladder |
| losses | INTEGER | Total losses in this ladder |
| points | INTEGER | Cumulative points (+20 per win) |
| joined_at | TIMESTAMPTZ | When player joined the ladder |
(ladder_id, user_id) — one entry per player per ladder. (ladder_id, position) — no two players share a rank.| Column | Type | Description |
|---|---|---|
| id | UUID PK | Match ID |
| ladder_id | UUID FK | Which ladder this match belongs to |
| challenge_id | UUID FK NULL | If from a private challenge; NULL for round matches |
| player1_id / player2_id | UUID FK | The two competitors |
| winner_id | UUID FK NULL | NULL = match not yet played; set = completed |
| score | TEXT | E.g. "6-3, 4-6, 6-4" (set-by-set) |
| scheduled_for | TIMESTAMPTZ NULL | Optional scheduled date/time |
| venue | TEXT NULL | Court or location description |
| played_at | TIMESTAMPTZ NULL | When result was recorded; NULL = pending |
| Column | Type | Description |
|---|---|---|
| id | UUID PK | Challenge ID |
| ladder_id | UUID FK | Which ladder the challenge is for |
| challenger_id | UUID FK | Who sent the challenge |
| challenged_id | UUID FK | Who received the challenge |
| status | ENUM | pending | accepted | declined | completed | expired |
| message | TEXT | Optional message from challenger |
| proposed_date | TIMESTAMPTZ | Suggested play date |
| expires_at | TIMESTAMPTZ | Auto-set to 7 days from creation |
A computed view joining clubs with an aggregated member count. Used in the Browse Clubs screen to avoid N+1 queries.
SELECT c.*, COUNT(cm.id)::int AS member_count
FROM clubs c
LEFT JOIN club_members cm ON cm.club_id = c.id
GROUP BY c.id;
The ladder system is fully automated. No admin action is needed once a ladder is created and players start joining.
Each ladder maintains an ordered list of players. Position 1 is the best. Every player has:
A "round" is a set of matches pairing all players in the ladder. Rounds are generated automatically — never manually by an admin.
A new round is generated when:
auto_round_on_join) fires on ladder_entries INSERT and calls generate_ladder_round().record_match_result() updates the match, it calls generate_ladder_round(). Since there are now zero pending matches, a fresh round is created.generate_ladder_round() checks if there are any unplayed matches (winner_id IS NULL AND challenge_id IS NULL) before generating. If pending matches exist, it does nothing and returns 0. This prevents duplicate rounds.
When a lower-ranked player beats a higher-ranked player, they swap positions. For example, if #4 beats #3, they exchange positions: the former #4 becomes #3, and the former #3 drops to #4.
This uses a 3-step swap inside a single transaction to respect the UNIQUE(ladder_id, position) constraint:
-- Step 1: Set winner to temp position -1
UPDATE ladder_entries SET position = -1
WHERE ladder_id = p_ladder_id AND user_id = winner;
-- Step 2: Move loser to winner's old position
UPDATE ladder_entries SET position = old_winner_pos
WHERE ladder_id = p_ladder_id AND user_id = loser;
-- Step 3: Set winner to loser's old position
UPDATE ladder_entries SET position = old_loser_pos
WHERE ladder_id = p_ladder_id AND user_id = winner;
| Event | Points Change |
|---|---|
| Win a match | +20 points |
| Lose a match | No change (0 points) |
| Win as lower-ranked player | +20 points + position improves |
When a match result is submitted via record_match_result(), the function checks if either player is already enrolled in the ladder. If not, they are automatically added at the bottom of the rankings. This allows challenge results to feed into the ladder even if the players joined through a private challenge rather than direct ladder enrollment.
Everything a player can do from signup through daily use.
Players sign up by choosing the "I am a Player" role, entering a username, full name, email, and password. A Supabase auth trigger automatically creates a profile record. On login, the session is stored securely via expo-secure-store.
The Browse Clubs tab lists all public clubs. If the player grants location permission, clubs are sorted by GPS distance (Haversine formula calculated client-side). Each club card shows the name, location, member count, and supported sports.
From a club detail page, non-members see a "Become a Member" button that inserts a club_members row with role member. Alternatively, players can use a club's invite code during signup.
From the ladder detail page, the player taps "Join Ladder." This inserts a ladder_entries row at position N+1 (bottom of the ladder). The auto_round_on_join trigger immediately fires and runs generate_ladder_round() — if conditions are met, a new round of matches is generated and the player will see their scheduled match in My Matches.
winner_id IS NULL, challenge_id IS NULL) where user is player1 or player2ladder_entries/match/newpending or accepted status where user is challenger or challengedwinner_id IS NOT NULL) for the userFrom any club detail page, every member sees a ⚡ button next to other members. Tapping opens /challenge/new, which shows the opponent's name, lets the player select which ladder the challenge is for (all active club ladders), add an optional message, and propose a date. The challenge expires automatically after 7 days.
The match result screen shows both players as selectable cards. The player taps the winner, then optionally enters the score set-by-set using +/- controls (e.g., Set 1: 6–3, Set 2: 4–6, Set 3: 6–4). On Submit, a single RPC call to record_match_result() handles everything atomically.
The profile tab shows aggregate stats (total wins, losses, win rate, ladder count), all enrolled ladders with position and sport, recent match history, and a sign-out button.
Club owners have a dedicated admin interface separate from the player experience.
First-time owners go through a 6-step animated wizard before accessing the dashboard:
| Step | Input | Purpose |
|---|---|---|
| 0 | Club name | Sets the public name for the club |
| 1 | Sports selection (multi-select) | Which sports this club offers |
| 2 | Court count per sport | Capacity info (displayed on club page) |
| 3 | Address + city | Location for discovery and distance sorting |
| 4 | Phone number | Contact info for members |
| 5 | Success screen | Creates club, sets up ladders, marks onboarding done |
Lists all members across all clubs the owner administers. For each member:
Lists all ladders grouped by club. For each ladder:
/ladder/manageReached via the Settings button on a ladder card. Contains:
Manage bracket tournaments separate from the ladder system:
A complete catalog of all 20+ screens in the app, their routes, and what they do.
supabase.auth.signInWithPassword(). On success routes to appropriate tab group based on user role. Link to signup.
supabase.auth.signUp() with metadata. Trigger auto-creates profile.
| Route | Name | Description |
|---|---|---|
/(tabs)/index |
My Ladders | Lists ladders user is enrolled in, with rank position. Tap to view ladder detail. |
/(tabs)/challenges |
My Matches | 3-tab view: Ladder (upcoming), Private (challenges), History (completed). Submit results, accept/decline challenges. |
/(tabs)/clubs |
Browse Clubs | All public clubs sorted by distance. Join, view details, create new club. |
/(tabs)/profile |
Profile | Personal stats, ladder cards, recent matches, sign out. |
| Route | Name | Description |
|---|---|---|
/(owner-tabs)/index |
Members | All club members. Toggle role, remove, view by club group. |
/(owner-tabs)/ladders |
Ladders | All ladders per club. Toggle active, create, delete, navigate to manage. |
/(owner-tabs)/tournaments |
Tournaments | Create and manage bracket tournaments. View enrollment status. |
/(owner-tabs)/club |
Club Settings | Edit club info, manage invite code, delete club, sign out. |
| Route | Name | Description |
|---|---|---|
/club/[id] |
Club Detail | Public club page. Join/leave, browse ladders, challenge members. |
/club/create |
Create Club | Simple form: name, description, location. Creates club + admin membership. |
/club/manage |
Club Admin Panel | Edit info, manage ladders (CRUD), manage members, danger zone. |
/ladder/[id] |
Ladder Detail | Standings table + scheduled/past matches. Join button for non-members. |
/ladder/create |
Create Ladder | Name, sport, season dates. Owner only. |
/ladder/manage |
Manage Ladder | Edit settings, view/remove players, reset rankings. Owner only. |
/challenge/new |
New Challenge | Challenge a club member. Select ladder, add message, propose date. |
/match/new |
Submit Result | Select winner, enter score with +/- set controls. Calls record_match_result(). |
/onboarding/club |
Club Onboarding | 6-step animated wizard for first-time club owners. |
All critical business logic runs inside the database as SECURITY DEFINER functions, ensuring atomicity and bypassing per-user RLS where cross-user writes are needed.
The most important function in the system. Called from the app after a match. Handles everything in a single atomic transaction.
| Parameter | Type | Description |
|---|---|---|
p_match_id | UUID (nullable) | Existing match to update. NULL = insert new match row. |
p_ladder_id | UUID | Which ladder the match belongs to |
p_player1_id | UUID | Player 1 |
p_player2_id | UUID | Player 2 |
p_winner_id | UUID | Who won |
p_score | TEXT (nullable) | Score string, e.g. "6-3, 4-6, 6-4" |
p_challenge_id | UUID (nullable) | Challenge ID if from private match |
Execution steps (all in one transaction):
matches row with winner, score, played_at = now()completed (if challenge_id provided)wins by 1 and points by 20losses by 1generate_ladder_round(p_ladder_id) to create next round if readyPairs adjacent-ranked players and inserts scheduled match rows.
FUNCTION generate_ladder_round(p_ladder_id uuid) RETURNS int
LANGUAGE plpgsql SECURITY DEFINER
-- Returns 0 if there are already unplayed non-challenge matches
-- Otherwise pairs #1 vs #2, #3 vs #4, ... and returns count created
Fires automatically after every new row in auth.users. Reads raw_user_meta_data (set during supabase.auth.signUp()) and creates the corresponding profiles row with username, full_name, is_club_owner, etc.
Fires after every INSERT on ladder_entries. Calls generate_ladder_round() for the new player's ladder. If at least 2 players are present and no pending matches exist, a new round is created immediately.
All tables have RLS enabled. The key policies:
| Table | Operation | Policy |
|---|---|---|
| profiles | SELECT | Public — anyone can view profiles |
| profiles | UPDATE | Own profile only (auth.uid() = id) |
| clubs | SELECT | Public |
| clubs | UPDATE/DELETE | Club admin only |
| club_members | INSERT | Self-join (auth.uid() = user_id) |
| ladder_entries | UPDATE | Own entry only — bypassed by SECURITY DEFINER functions |
| challenges | SELECT | Challenger or challenged party only |
| matches | SELECT | Public |
| matches | INSERT/UPDATE | Bypassed by record_match_result() RPC |
ladder_entries row (their wins, losses, position). RLS blocks this because auth.uid() ≠ player_b_id. The record_match_result() function runs as the database owner (bypassing RLS), making all updates in one trusted atomic block.
Step-by-step walkthroughs of every major action in the app.
record_match_result() called. Stats updated, positions swapped if needed, next round generated. Match moves to History tab.PlayRise uses a dark UI with a teal primary color, consistent across both iOS and Android.
| Token | Hex | Usage |
|---|---|---|
background | #0A0A0F | App background (darkest) |
surface | #12121A | Secondary backgrounds, input fills |
card | #1A1A26 | Cards, list items, modals |
cardBorder | #2A2A3A | Subtle card borders |
primary | #00D4AA | Primary actions, highlights, CTAs |
accent | #7B61FF | Secondary actions, info elements |
danger | #FF4D6A | Delete, remove, destructive |
warning | #FF9F43 | Badges, pending states |
gold | #F5C542 | #1 position badge |
silver | #C0C0C0 | #2 position badge |
bronze | #CD7F32 | #3 position badge |
| Sport | Emoji | Color | Applied To |
|---|---|---|---|
| Tennis | 🎾 | #8BC34A (Green) | Ladder headers, sport badges, season banners |
| Padel | 🏸 | #FF6B35 (Orange) | Ladder headers, sport badges, season banners |
| Squash | 🟡 | #7B61FF (Purple) | Ladder headers, sport badges, season banners |
borderRadius: 16, buttons borderRadius: 14, inputs borderRadius: 12.router.canGoBack() ? router.back() : router.replace(tabRoot) to prevent navigation dead-ends.@expo/vector-icons throughout.All API calls are made through the Supabase JS client. No custom REST endpoints.
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
import * as SecureStore from 'expo-secure-store'
const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY,
{
auth: {
storage: SecureStore, // encrypted token storage
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
}
}
)
// Submit a match result (core function)
await supabase.rpc('record_match_result', {
p_match_id: matchId ?? null,
p_ladder_id: ladderId,
p_player1_id: player1Id,
p_player2_id: player2Id,
p_winner_id: winnerId,
p_score: "6-3, 4-6, 6-4",
p_challenge_id: challengeId ?? null,
})
// Manually trigger round generation
await supabase.rpc('generate_ladder_round', {
p_ladder_id: ladderId
})
// Seed mock players for testing
await supabase.rpc('seed_mock_players', {
p_club_id: clubId
})
// Fetch ladder with club and entries
supabase.from('ladders')
.select('*, club:clubs(id, name)')
.eq('id', ladderId)
.single()
// Fetch standings (entries with profile)
supabase.from('ladder_entries')
.select('*, profile:profiles(username, full_name)')
.eq('ladder_id', ladderId)
.order('position')
// Fetch scheduled (unplayed) ladder matches
supabase.from('matches')
.select('*, player1:profiles!matches_player1_id_fkey(...), ...')
.eq('ladder_id', ladderId)
.is('winner_id', null)
// Fetch user's pending challenges (received)
supabase.from('challenges')
.select('*, challenger:profiles!challenger_id(*), ladder:ladders(*)')
.eq('challenged_id', userId)
.eq('status', 'pending')
// Sign up
supabase.auth.signUp({
email, password,
options: { data: { username, full_name, is_club_owner } }
})
// Sign in
supabase.auth.signInWithPassword({ email, password })
// Sign out
supabase.auth.signOut()
// Get current session
supabase.auth.getSession()
// Listen for auth changes
supabase.auth.onAuthStateChange((event, session) => { ... })
How to set up and run the PlayRise project locally.
npm install -g @expo/cli# Clone the repository
git clone <repo-url> PlayRise
cd PlayRise
# Install dependencies
npm install
# Create environment file
cp .env.example .env
# .env
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
Run each migration file in order in the Supabase SQL editor:
supabase/migrations/
20260315000000_initial_schema.sql ← Run first
20260316000000_owner_features.sql
20260316000001_fix_owner_trigger.sql
20260316000002_clubs_location.sql
20260316000003_scheduled_matches.sql
20260316000004_mock_players.sql
20260316000005_auto_rounds.sql
20260316000006_record_match_result.sql ← Run last
# Start Expo dev server
npx expo start
# Options:
npx expo start --ios # iOS Simulator
npx expo start --android # Android Emulator
# Or scan QR code with Expo Go on device
-- Run in Supabase SQL editor after getting a club ID
SELECT seed_mock_players('your-club-uuid-here');
-- Creates 8 mock players, enrolls them in all active ladders,
-- generates past matches for realistic data
| Command | Description |
|---|---|
npx expo start | Start development server |
npx expo start --clear | Start with cleared cache |
npx expo build | Production build via EAS |
npx tsc --noEmit | TypeScript type check (no output) |
Commercial pricing tiers for selling PlayRise to sports clubs and facilities
PlayRise is sold as a SaaS subscription to clubs and sports facilities. Each club gets a dedicated workspace — their own members, ladders, tournaments, and branding. The three tiers below are designed to match clubs by size: from small recreational clubs just getting started, to large multi-sport facilities needing full automation and priority support.
| Feature | Starter · $120/yr | Pro · $219/yr | Elite · $399/yr |
|---|---|---|---|
| Active ladders | 1 | Up to 5 | Unlimited |
| Players per ladder | 8 | 50 | Unlimited |
| Seasons per year | 1 (seasonal) | Unlimited | Unlimited |
| Tournaments | ✗ | 3/season | Unlimited |
| Challenge system | ✗ | ✓ | ✓ |
| Multi-sport support | ✗ | ✓ | ✓ |
| Analytics dashboard | ✗ | Basic | Advanced + CSV export |
| Custom club branding | ✗ | Name & badge | Full white-label |
| Multiple admin accounts | ✗ | ✗ | ✓ |
| Push notification campaigns | ✗ | ✗ | ✓ |
| API access | ✗ | ✗ | ✓ |
| Auto scheduling & reminders | ✗ | ✗ | ✓ |
| Support SLA | 72h email | 24h priority email | 4h · Direct Slack |
| Onboarding | Self-serve docs | Guided setup | Dedicated video call |
Any plan can be extended with the following paid add-ons, billed monthly.
| Plan | Annual Price | Billing | Best for |
|---|---|---|---|
| Starter | $120 / year | Yearly · one payment | Small clubs, 1 sport, ≤ 8 players |
| Pro | $219 / year | Yearly · one payment | Active clubs, multi-sport, 10–50 players |
| Elite | $399 / year | Yearly · one payment | Large facilities, academies, no limits |