DEV Community

Arshad Khan
Arshad Khan

Posted on

I Built a Snooker Scoring PWA Because the Game Room Scoreboard Only Had 2 Slots

I Built a Snooker Scoring PWA Because the Game Room Scoreboard Only Had 2 Slots

We were three friends. One scoreboard. Zero solutions.

Every time my friends and I hit the snooker table, we'd run into the same problem — the scoreboard on the wall was built for two players only. One of us always had to manually keep score in their head, or on a piece of paper, or just... trust the other two. Not ideal when points are on the line.

So I did what any developer would do. I built an app.

That app is SnookerBee — a mobile-first Progressive Web App for tracking snooker scores, built with React 19, TypeScript, Vite, Supabase, and Google OAuth. It's live at snookerbee.vercel.app and the full source is on GitHub.

Here's how I built it, what I learned, and why a PWA was the right choice.

The Problem

Snooker is typically played 1v1. Most scoreboards, apps, and trackers assume this. But snooker rooms often have 3-4 people sharing a table in a free-for-all — rotating turns, individual scores, tracking who won which frame.

I couldn't find an app that:

Supported more than 2 players properly
Worked offline (game rooms often have spotty Wi-Fi)
Felt designed for snooker specifically — not a generic "sports tracker"
Was installable on mobile without going through an App Store

So I decided to build exactly that.

Tech Stack

LayerTechnologyFrontendReact 19 + TypeScriptBuild ToolViteRoutingReact Router v7StylingVanilla CSS (custom tokens, glassmorphism)PWAvite-plugin-pwa + WorkboxAudioWeb Audio APIBackendSupabase (PostgreSQL)AuthGoogle OAuth via Supabase AuthHostingVercel

Features

🎮 Three Game Modes

1v1 — Standard head-to-head
Teams — 2v2 or 3v3 alternating configurations
Free-for-All — 2 to 8 players, round-robin turns

This was the core reason I built it — Free-for-All mode handles exactly the situation my friends and I were in.

⚖️ Strict Rules Engine

The scoring logic is built as a pure state-machine reducer. It handles:

Official red-color sequence enforcement
Free ball nomination
Re-spotted black on tied scores
Turn rotation management across all game modes
10-step deep-cloned undo stack

Getting this right was honestly the hardest part of the project. Snooker rules have a lot of edge cases — especially around fouls, free balls, and end-game scenarios.

🔊 Sound Effects — Zero Dependencies

Instead of importing a sound library, I used the Web Audio API directly — oscillators, gains, and envelopes — to synthesize:

Pot confirmation chimes
Foul warning buzzes
Break milestone sounds (25, 50, 100 points)
Victory fanfare

This keeps the bundle lean and works completely offline.

📶 Offline-First PWA

Using vite-plugin-pwa with Workbox caching strategies, the app:

Installs directly to your phone homescreen
Works without internet after first load
Has a custom app icon

This was critical for the game room use case — you don't want your scoring app to fail mid-match because of Wi-Fi.

☁️ Cloud Sync with Supabase

When you're signed in with Google, SnookerBee syncs:

Full match history
Player statistics (highest break, frames won, fouls)
Frame-level action logs

The database uses Row Level Security (RLS) so each user can only access their own data.

Database Schema

Three tables power the cloud sync:

sql-- Matches table
create table matches (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
mode text not null,
reds_count int not null,
best_of int not null,
created_at timestamptz default now(),
duration_ms bigint not null,
winner_name text not null
);

-- Players per match
create table match_players (
id uuid primary key default gen_random_uuid(),
match_id uuid references matches(id) on delete cascade,
player_name text not null,
team_name text,
total_score int default 0,
highest_break int default 0,
frames_won int default 0,
fouls_committed int default 0,
time_spent_ms bigint default 0
);

-- Frame-level logs
create table match_frames (
id uuid primary key default gen_random_uuid(),
match_id uuid references matches(id) on delete cascade,
frame_number int not null,
duration_ms bigint,
action_log jsonb
);

All three tables have RLS enabled with user-scoped policies.

Lessons Learned

  1. PWA is underrated for sports apps
    The offline capability + homescreen install covers 90% of what a native app gives you, without the App Store overhead. For a game room tool, this is ideal.

  2. Web Audio API is powerful and underused
    I expected to need a library for sounds. Turns out the native API handles everything I needed with less than 100 lines of code, and zero extra bundle weight.

  3. State machines for game logic are worth the upfront cost
    I almost built the scoring logic with scattered useState calls. Switching to a reducer-based state machine early saved me from dozens of bugs later — especially with undo functionality.

  4. Supabase RLS is your friend
    Row Level Security meant I never had to write server-side authorization code. The database enforces it at the query level.

What's Next

Android app via TWA (Trusted Web Activity) / Bubblewrap — Play Store submission
iOS app via Capacitor.js wrapper
Tournament mode — bracket-style multi-match tournaments
Bluetooth scoreboard integration (experimenting)

Try It

🌐 Live App: snookerbee.vercel.app
💻 GitHub: github.com/arshad0126/snookerbee

If you play snooker with more than one friend — give it a try. And if you find a bug or want to contribute, PRs are welcome.

Built with React 19, TypeScript, Vite, Supabase, and a lot of missed pots.

Tags: #webdev #react #pwa #supabase #typescript #buildinpublic #javascript #opensource

Top comments (0)