PlayRise

Sports Ladder & Club Management Platform

React Native Supabase Expo SDK 55 TypeScript Tennis · Padel · Squash
Document Version 1.0 · March 2026
Audience Development Team · Sales Team
Status ● Active Development

Table of Contents

00Interactive Demo
Live clickable UI mockup — explore all screens
01Product Overview
Vision · Key differentiators · Supported sports
02User Types & Roles
Players · Club Owners · Permissions matrix
03Technology Stack
Frontend · Backend · Infrastructure · Dependencies
04Application Architecture
Navigation structure · Routing · File structure
05Database Schema
All tables · Columns · Relationships · Enums · Views
06Ladder System — Core Logic
How rankings work · Round generation · Position swap · Points
07Feature Walkthrough — Player
Signup · Browse clubs · Join ladder · My Matches · Challenges · Profile
08Feature Walkthrough — Club Owner
Onboarding · Members · Ladders · Tournaments · Club settings
09Screen Reference
Every screen, its purpose, data, and actions
10Backend Functions & Security
RPC functions · Triggers · Row Level Security · SECURITY DEFINER
11Key User Flows
End-to-end journeys for every major use case
12Design System & Colors
Color palette · Sport theming · UI conventions
13API Reference
Supabase tables · RPC signatures · Auth endpoints
14Development Setup
Installation · Environment · Running the app · Migrations
15Subscription Plans
Starter · Pro · Elite — pricing tiers for club sales
00 — INTERACTIVE DEMO

Try the App

A fully interactive mockup of PlayRise — tap screens, enter scores, switch tabs, send challenges. Use the navigation buttons or tap directly inside the phone.

9:41
▐▐▐WiFi🔋
📱 Screen: My Ladders
🗺️ Navigate to any screen
💡 What to try on this screen
  • Tap any ladder card to view standings
  • Use the bottom tabs to switch sections
📊 Demo Player Stats (James Kim)
3
LADDERS
#1
BEST RANK
11
TOTAL WINS
2
PENDING
58%
WIN RATE
10
LOSSES
🔑 Key interactions to show
For developers: Tap Submit Result → select winner → use +/− to build set score → tap Submit to see the confirmation flow.

For sales: Show Browse Clubs → tap City Racket Club → tap ⚡ next to a member → see the Challenge flow. Then go to Ladder Detail → Standings to show the live ranking table.
01 — PRODUCT OVERVIEW

What is PlayRise?

PlayRise is a mobile-first sports management platform that brings automated ladder rankings, private challenges, and club administration to racket sports clubs.

3
Supported Sports
2
User Roles
8
Database Tables

Core Value Proposition

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.

🏆
Automated Ladder Rankings
No manual management required

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.

Private Challenges
Any two club members can challenge each other

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 Management
Full admin control for club owners

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.

Supported Sports

🎾 Tennis 🏸 Padel 🟡 Squash

Each sport has a dedicated color theme and emoji identifier used consistently across the UI for quick recognition.

Platform

02 — USER TYPES & ROLES

Who Uses PlayRise?

PlayRise has two distinct user types, each with a completely separate navigation experience, dashboard, and set of capabilities.

🎾 Player
Browse and join clubs
Join any active ladder
View ladder standings & rankings
Submit match results
Send and receive challenges
View match history
Track personal stats & points
See upcoming scheduled matches
🏟️ Club Owner
All Player capabilities
Create and manage clubs
Create and configure ladders
Add/remove/promote members
Create and manage tournaments
Reset player rankings
Delete ladders and clubs
Generate and share invite codes

Role Detection

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.

Navigation Split: After login, the app reads 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.

Club Roles (within a Club)

RoleDescriptionPermissions
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

Challenge Status Flow

pending
accepted
completed
pending
declined
pending
expired
(after 7 days)
03 — TECHNOLOGY STACK

Built On

PlayRise uses a modern, production-ready stack with minimal infrastructure overhead — the backend is fully managed by Supabase.

Frontend

TechnologyVersionPurpose
React Native0.83.2Cross-platform mobile UI framework
ExpoSDK 55Build toolchain, native module access, OTA updates
React19.2.0Component model, hooks, state management
TypeScript5.9.2Type safety throughout the codebase
Expo Routerv4File-based navigation (like Next.js for mobile)
@expo/vector-iconsIonicons icon set
expo-locationGPS for club distance sorting
expo-secure-storeEncrypted token storage

Backend (Supabase)

ServicePurpose
PostgreSQL 15Primary database — all app data
Supabase AuthUser registration, login, session management, JWT tokens
Row Level SecurityDatabase-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
Zero backend code: PlayRise has no custom API server. All business logic runs either in the React Native client or in PostgreSQL stored procedures (SECURITY DEFINER functions). This keeps infrastructure minimal and latency low.

Key Architectural Choices

🔐
SECURITY DEFINER Functions

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().

File-based Routing (Expo Router)

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.

📦
Migration-based Schema

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.

04 — APPLICATION ARCHITECTURE

Structure & Navigation

The app is built around Expo Router's file-based routing system, with separate tab groups for each user type.

Navigation Tree

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

Auth & Routing Logic

The root _layout.tsx manages routing decisions using three pieces of state from AuthContext:

No session
User sees login/signup screens at /(auth)
Session + isClubOwner + !onboarding_completed
Owner redirected to /onboarding/club wizard
Session + isClubOwner + onboarding_completed
Owner lands on /(owner-tabs) with Members, Ladders, Tournaments, Club tabs
Session + regular player
Player lands on /(tabs) with My Ladders, My Matches, Browse Clubs, Profile tabs

Project File Structure

DirectoryContents
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
05 — DATABASE SCHEMA

Data Model

PlayRise uses 8 PostgreSQL tables plus 1 view. All tables have Row Level Security enabled.

Entity Relationship Overview

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

Table: profiles

ColumnTypeDescription
idUUID PKReferences auth.users.id
usernameTEXT UNIQUEPublic handle (e.g. @alex_smith)
full_nameTEXTDisplay name
avatar_urlTEXTProfile picture URL
phoneTEXTOptional phone number
is_club_ownerBOOLEANDetermines navigation and UI role
onboarding_completedBOOLEANWhether owner wizard has been completed
created_atTIMESTAMPTZAccount creation timestamp

Table: clubs

ColumnTypeDescription
idUUID PKAuto-generated club ID
nameTEXT NOT NULLClub display name
descriptionTEXTOptional club bio
locationTEXTAddress or city
logo_urlTEXTClub logo image URL
latitude / longitudeFLOAT8GPS coordinates for distance sorting
created_byUUID FKOwner's profile ID
invite_codeTEXT UNIQUE8-char code for sharing club membership
created_atTIMESTAMPTZCreation timestamp

Table: ladders

ColumnTypeDescription
idUUID PKLadder ID
club_idUUID FKParent club
sportENUMtennis | padel | squash
nameTEXT NOT NULLLadder display name
is_activeBOOLEANToggleable by owner — inactive ladders are read-only
season_start / season_endDATEOptional season date range for display
created_atTIMESTAMPTZCreation timestamp

Table: ladder_entries

ColumnTypeDescription
idUUID PKEntry ID
ladder_idUUID FKParent ladder
user_idUUID FKPlayer's profile ID
positionINTEGER NOT NULLCurrent rank (1 = best). UNIQUE per ladder.
winsINTEGERTotal wins in this ladder
lossesINTEGERTotal losses in this ladder
pointsINTEGERCumulative points (+20 per win)
joined_atTIMESTAMPTZWhen player joined the ladder
Unique constraints: (ladder_id, user_id) — one entry per player per ladder. (ladder_id, position) — no two players share a rank.

Table: matches

ColumnTypeDescription
idUUID PKMatch ID
ladder_idUUID FKWhich ladder this match belongs to
challenge_idUUID FK NULLIf from a private challenge; NULL for round matches
player1_id / player2_idUUID FKThe two competitors
winner_idUUID FK NULLNULL = match not yet played; set = completed
scoreTEXTE.g. "6-3, 4-6, 6-4" (set-by-set)
scheduled_forTIMESTAMPTZ NULLOptional scheduled date/time
venueTEXT NULLCourt or location description
played_atTIMESTAMPTZ NULLWhen result was recorded; NULL = pending

Table: challenges

ColumnTypeDescription
idUUID PKChallenge ID
ladder_idUUID FKWhich ladder the challenge is for
challenger_idUUID FKWho sent the challenge
challenged_idUUID FKWho received the challenge
statusENUMpending | accepted | declined | completed | expired
messageTEXTOptional message from challenger
proposed_dateTIMESTAMPTZSuggested play date
expires_atTIMESTAMPTZAuto-set to 7 days from creation

View: clubs_with_member_count

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;
06 — LADDER LOGIC

How the Ranking System Works

The ladder system is fully automated. No admin action is needed once a ladder is created and players start joining.

Ranking Model

Each ladder maintains an ordered list of players. Position 1 is the best. Every player has:

Visual Example

1
Alex Smith 8W · 2L · 160pts vs #2 →
2
Maria Jones 7W · 3L · 140pts vs #1 →
3
James Kim 6W · 4L · 120pts vs #4 →
4
Sara Park 5W · 5L · 100pts vs #3 →
5
Tom Voss 4W · 6L · 80pts vs #6 →
6
Lena Meyer 3W · 7L · 60pts vs #5 →

Round Generation

A "round" is a set of matches pairing all players in the ladder. Rounds are generated automatically — never manually by an admin.

Pairing algorithm: Players are sorted by current position. Adjacent pairs are matched: #1 vs #2, #3 vs #4, #5 vs #6, etc. If there's an odd number of players, the last player gets a bye (no match that round).

A new round is generated when:

  1. A player joins the ladder — a PostgreSQL trigger (auto_round_on_join) fires on ladder_entries INSERT and calls generate_ladder_round().
  2. The last pending match in a round gets a result — after record_match_result() updates the match, it calls generate_ladder_round(). Since there are now zero pending matches, a fresh round is created.
Guard condition: 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.

Position Swap Logic

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;

Points System

EventPoints Change
Win a match+20 points
Lose a matchNo change (0 points)
Win as lower-ranked player+20 points + position improves

Auto-Enrollment

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.

07 — PLAYER FEATURES

Feature Walkthrough: Player

Everything a player can do from signup through daily use.

1. Signup & Login

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.

2. Browse Clubs

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.

3. Join a Club

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.

4. Join a Ladder

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.

5. My Matches — Three Tabs

🏆
Ladder Tab
Upcoming round-generated matches
Private Tab
Challenges sent and received
📋
History Tab
All completed matches

6. Send a Challenge

From 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.

7. Submit a Match Result

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.

8. Player Profile

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.

08 — CLUB OWNER FEATURES

Feature Walkthrough: Club Owner

Club owners have a dedicated admin interface separate from the player experience.

1. Club Onboarding Wizard

First-time owners go through a 6-step animated wizard before accessing the dashboard:

StepInputPurpose
0Club nameSets the public name for the club
1Sports selection (multi-select)Which sports this club offers
2Court count per sportCapacity info (displayed on club page)
3Address + cityLocation for discovery and distance sorting
4Phone numberContact info for members
5Success screenCreates club, sets up ladders, marks onboarding done

2. Members Tab

Lists all members across all clubs the owner administers. For each member:

3. Ladders Tab

Lists all ladders grouped by club. For each ladder:

4. Ladder Management Screen

Reached via the Settings button on a ladder card. Contains:

5. Tournaments Tab

Manage bracket tournaments separate from the ladder system:

6. Club Settings Tab

Invite Codes: Each club has a unique 8-character alphanumeric code. Players can enter this code during signup to automatically join the club as a member. Codes can be regenerated at any time from the Club Settings tab.
09 — SCREEN REFERENCE

Every Screen

A complete catalog of all 20+ screens in the app, their routes, and what they do.

Authentication Screens

/login
Login Screen — Email + password form. Calls supabase.auth.signInWithPassword(). On success routes to appropriate tab group based on user role. Link to signup.
/signup
Signup Screen — Role selector (Player / Club Owner). Username, full name, email, password. Optional invite code for club owners. Calls supabase.auth.signUp() with metadata. Trigger auto-creates profile.

Player Screens

RouteNameDescription
/(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.

Owner Screens

RouteNameDescription
/(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.

Shared Stack Screens

RouteNameDescription
/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.
10 — BACKEND FUNCTIONS & SECURITY

PostgreSQL Functions & RLS

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.

record_match_result() — Core RPC

The most important function in the system. Called from the app after a match. Handles everything in a single atomic transaction.

ParameterTypeDescription
p_match_idUUID (nullable)Existing match to update. NULL = insert new match row.
p_ladder_idUUIDWhich ladder the match belongs to
p_player1_idUUIDPlayer 1
p_player2_idUUIDPlayer 2
p_winner_idUUIDWho won
p_scoreTEXT (nullable)Score string, e.g. "6-3, 4-6, 6-4"
p_challenge_idUUID (nullable)Challenge ID if from private match

Execution steps (all in one transaction):

  1. Create or update the matches row with winner, score, played_at = now()
  2. Mark the challenge as completed (if challenge_id provided)
  3. Auto-enroll any player not yet in the ladder at the bottom position
  4. Increment winner's wins by 1 and points by 20
  5. Increment loser's losses by 1
  6. If winner's position > loser's position: perform the 3-step position swap
  7. Call generate_ladder_round(p_ladder_id) to create next round if ready
  8. Return the match UUID

generate_ladder_round() — Auto Round Creator

Pairs 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

handle_new_user() — Signup Trigger

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.

trg_auto_round_on_join() — Join Trigger

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.

Row Level Security (RLS)

All tables have RLS enabled. The key policies:

TableOperationPolicy
profilesSELECTPublic — anyone can view profiles
profilesUPDATEOwn profile only (auth.uid() = id)
clubsSELECTPublic
clubsUPDATE/DELETEClub admin only
club_membersINSERTSelf-join (auth.uid() = user_id)
ladder_entriesUPDATEOwn entry only — bypassed by SECURITY DEFINER functions
challengesSELECTChallenger or challenged party only
matchesSELECTPublic
matchesINSERT/UPDATEBypassed by record_match_result() RPC
Why SECURITY DEFINER? When Player A submits a match result, the system needs to update Player B's 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.
11 — KEY USER FLOWS

End-to-End Journeys

Step-by-step walkthroughs of every major action in the app.

Flow 1: New Player Signup to First Match

Open app → Signup
Select "I am a Player", enter credentials. Auth trigger creates profile row.
Land on My Ladders tab
Empty state shows "Browse Clubs" button.
Browse Clubs → Find club → "Become a Member"
Inserts club_members row. Club detail now shows ladder list.
Tap a Ladder → "Join Ladder"
Inserts ladder_entries row. Trigger fires → round generated → scheduled match created.
Go to My Matches → Ladder tab
Scheduled match appears showing opponent's name and position.
Tap "Submit Result" → Enter winner + score → Submit
record_match_result() called. Stats updated, positions swapped if needed, next round generated. Match moves to History tab.

Flow 2: Club Owner Onboarding

Signup as Club Owner
Auth trigger creates profile with is_club_owner=true, onboarding_completed=false.
Routed to Onboarding Wizard
6 animated steps. Progress bar tracks completion.
Complete wizard → Success screen
Club created, owner added as admin, ladders created for chosen sports, onboarding_completed=true.
Land on Owner Dashboard
Members, Ladders, Tournaments, Club tabs. Ready to invite players via invite code.

Flow 3: Private Challenge

Player A views Club page → sees Player B → taps ⚡
Opens /challenge/new with Player B pre-filled.
Select ladder, add message + date → "Send Challenge"
Challenge row created with status=pending. Appears in Player B's My Matches → Private tab.
Player B sees notification badge → taps "Accept"
Challenge status → accepted. Both players see it as active.
Either player taps "Submit Result" → selects winner + score → Submit
record_match_result() runs atomically. Challenge → completed. Standings updated. Next round generated.

Flow 4: Position Swap (Lower-Ranked Wins)

Round generates: #3 vs #4
match row inserted with winner_id=NULL.
Player at #4 wins the match
record_match_result() detects winner_position(4) > loser_position(3).
3-step swap executes
#4 → -1 (temp) → #3 (final). #3 → #4. Ladder now reflects new order. +20 pts to winner.
12 — DESIGN SYSTEM & COLORS

Visual Identity

PlayRise uses a dark UI with a teal primary color, consistent across both iOS and Android.

Color Palette

TokenHexUsage
background#0A0A0FApp background (darkest)
surface#12121ASecondary backgrounds, input fills
card#1A1A26Cards, list items, modals
cardBorder#2A2A3ASubtle card borders
primary#00D4AAPrimary actions, highlights, CTAs
accent#7B61FFSecondary actions, info elements
danger#FF4D6ADelete, remove, destructive
warning#FF9F43Badges, pending states
gold#F5C542#1 position badge
silver#C0C0C0#2 position badge
bronze#CD7F32#3 position badge

Sport Theming

SportEmojiColorApplied 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

UI Conventions

13 — API REFERENCE

Supabase API

All API calls are made through the Supabase JS client. No custom REST endpoints.

Supabase Client Config

// 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,
    }
  }
)

Key RPC Calls

// 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
})

Common Table Queries

// 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')

Auth Calls

// 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) => { ... })
14 — DEVELOPMENT SETUP

Getting Started

How to set up and run the PlayRise project locally.

Prerequisites

Installation

# Clone the repository
git clone <repo-url> PlayRise
cd PlayRise

# Install dependencies
npm install

# Create environment file
cp .env.example .env

Environment Variables

# .env
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here

Database Setup

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

Running the App

# 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

Seeding Test Data

-- 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

Migration Notes

Order matters. Migrations must be applied in chronological order. Each file assumes the previous ones have been applied. Migration 00005 and 00006 include retroactive DO blocks that run immediately to backfill existing data.

Project Scripts

CommandDescription
npx expo startStart development server
npx expo start --clearStart with cleared cache
npx expo buildProduction build via EAS
npx tsc --noEmitTypeScript type check (no output)

🎾
PlayRise v1.0
Sports Ladder & Club Management Platform
React Native · Expo SDK 55 · Supabase · TypeScript
Documentation generated March 2026
15

Subscription Plans

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.

Billing model: All plans are billed yearly — one flat annual payment. Pricing is per club, not per member — a club pays one fee regardless of how many players sign up. All plans include onboarding support.
🌱
Starter
For small clubs testing the waters with their first digital ladder.
$120/yr
Billed yearly · one payment

  • 1 ladder — single active ladder at all times
  • Up to 8 players per ladder
  • Seasonal — one season per calendar year
  • Match result submission
  • Live standings & rankings
  • Player profiles & match history
  • Email support (72h response)
  • Challenge system
  • Tournaments
  • Analytics dashboard
  • Multiple sports
  • Custom club branding
Get Started
Most Popular 🚀
Pro
For active clubs running multiple sports and competitive ladders year-round.
$219/yr
Billed yearly · one payment

  • Up to 5 simultaneous ladders
  • Up to 50 players per ladder
  • Unlimited seasons — start & archive anytime
  • Up to 3 tournaments per season
  • Challenge system — player-to-player match requests
  • Multi-sport — tennis, padel, squash in one club
  • Auto round generation & scheduling
  • Points & ranking history per player
  • Club analytics — match volume, activity trends
  • Custom club name & sport badge
  • Member management (invite/remove)
  • Priority email support (24h response)
Start Free Trial
Best Value 👑
Elite
Unlimited everything — built for large clubs, academies, and multi-venue facilities.
$399/yr
Billed yearly · one payment

  • Unlimited ladders — no cap, ever
  • Unlimited players per ladder
  • Unlimited tournaments & seasons
  • Everything in Pro, plus:
  • White-label branding — club logo, custom colors
  • Advanced analytics — player progression, heatmaps, export to CSV
  • Multiple club owners / admin accounts
  • Automated season scheduling & reminders
  • Push notification campaigns to all members
  • API access for integrations (booking systems, website)
  • Dedicated onboarding session (video call)
  • Priority support — 4h SLA, direct Slack channel
Contact Sales

Feature Comparison

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

Available Add-ons

Any plan can be extended with the following paid add-ons, billed monthly.

🏆
Extra Tournament Pack
Add 5 more tournaments per season to any Starter or Pro plan.
+$19 / mo
📊
Advanced Analytics
Player progression charts, activity heatmaps, and CSV export. (Pro only)
+$29 / mo
🎨
White-label Branding
Custom logo, colors, and club name shown to all members. (Pro only)
+$39 / mo
👥
Extra Admin Seats
Add additional club owner / admin accounts (up to 3 extra).
+$15 / mo
🔔
Push Notifications
Broadcast match reminders and announcements to all members. (Starter/Pro)
+$19 / mo
🔗
API Access
Integrate PlayRise data with your website, booking system, or CRM.
+$49 / mo

Sales Notes for the Team

Free Trial: Offer all new clubs a 14-day free trial of the Pro plan — no credit card required. This lets them experience challenges, multi-sport, and analytics before committing.
Yearly billing only: All plans are paid annually in one upfront payment. This keeps pricing simple for clubs and ensures predictable revenue. Emphasise the low yearly cost when pitching — $120/yr works out to just $10/month for the whole club.
Upgrade triggers: Clubs on Starter will naturally hit the 8-player cap. When a club wants to add a 9th player, prompt them to upgrade to Pro. Clubs on Pro hit the 50-player or 5-ladder cap — prompt to Elite. These are natural conversion points built into the product.

Pricing Summary

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

💼
Ready to close a deal?
Start every prospect on the Pro 14-day trial. Most clubs convert within the first week once their players are active on the app.