My rugby club needed a livescore system. Existing solutions - FederationRugby, SportEasy - are either limited, paid, or not suited to Belgian rugby. I decided to build my own: myrugby.be.
The brief was simple: a coach on the sideline taps "Try" on his phone, and parents watching from afar see the score change live. With a push notification on every action.
Overview
The system is built on 4 layers:
- The public interface - a real-time scoreboard per club, accessible without login
- The coach interface - an installable PWA to manage matches and score live
- The PHP backend - a custom MVC with MySQL and PDO
- Push notifications - Firebase Cloud Messaging (FCM) with Service Account
The data model
The MySQL schema is centered on the relationship Club → Team → Game → Score. Each club can have multiple teams (U16, U18, Seniors...), each team plays matches, and each match accumulates scores:
Clubs (ClubID, ClubName, ClubSlug, LogoName, City)
|-- Teams (TeamID, ClubID, TeamName, DisplayName)
| |-- Games (GameID, OwnerClubID, HomeTeamID, AwayClubID, GameStatus, Date)
| |-- Scores (ScoreID, GameID, IsOwnerTeam, ScoreType, Points, Timestamp)
|
|-- Users (UserID, ClubID, Username, Role: superadmin|admin|coach)
|-- PushSubscriptions (FCMToken, ClubID, TeamID, GameID, DeviceType)
The lifecycle of a match
A match goes through 5 states, managed by a PHP constant:
define('GAME_STATUS', [
'NOT_STARTED' => 'Non commence',
'FIRST_HALF' => '1ere mi-temps',
'HALF_TIME' => 'Mi-temps',
'SECOND_HALF' => '2eme mi-temps',
'FINISHED' => 'Termine'
]);
The coach moves the status forward manually - no automatic timer. In rugby, stoppages make any automatic clock unusable. The match can also be put on pause (injury, card), and the pause time is subtracted from the displayed clock.
Score types
Three possible actions, each with its own points:
These colored circles are the visual signature of the coach interface. At a glance, you see the score breakdown - not just the total.
The PHP backend: MVC without a framework
No Laravel, no Symfony. A custom MVC with an abstract Model class that provides basic CRUD:
abstract class Model {
protected PDO $db;
protected string $table;
public function __construct() {
$this->db = Database::getInstance(); // Singleton PDO
}
public function findAll(string $orderBy = null): array { ... }
public function findById(int $id): ?array { ... }
public function findBy(array $conditions): array { ... }
public function create(array $data): int { ... }
public function update(int $id, array $data): bool { ... }
public function delete(int $id): bool { ... }
}
Each model (Club, Team, Game, Score, User) inherits from this class and adds its specific methods. The Database uses the Singleton pattern with automatic environment detection (local vs production).
Why not a framework?
For a project of this size (~1,700 lines of backend), a framework would be over-engineering. The needs are simple: basic routing (one PHP file per page), direct SQL queries, native sessions. The custom MVC does the job without the 50 MB of vendor/.
Security
- CSRF - token generated per session, verified on every POST
- Passwords -
bcryptviapassword_hash()/password_verify() - SQL injection - prepared PDO statements everywhere
- XSS -
htmlspecialchars()on all outputs - Remember Me - persistent token in DB with 1-year expiration
Real-time without WebSocket
This is the technically most interesting part. How do you display "live" scores without WebSocket, without Firebase, without a Node server?
Answer: smart polling with MD5 signature.
The principle
Every 3 seconds, the spectator's browser calls an API that returns the current state of the matches and an MD5 hash of that state:
// API /api/games/status.php
$state = [];
foreach ($games as $g) {
$state[] = $g['GameID'] . ':' . $g['GameStatus']
. ':' . $g['home_score'] . '-' . $g['away_score']
. ':' . ($g['IsPaused'] ?? 0);
}
$signature = md5(implode('|', $state));
echo json_encode([
'games' => $games,
'signature' => $signature,
'timestamp' => time()
]);
Client side: conditional reload
The JavaScript compares the signature with the previous one. If it changed, the page reloads. Otherwise, nothing happens:
let lastSignature = null;
setInterval(() => {
fetch('/api/games/status.php?club=' + clubId)
.then(r => r.json())
.then(data => {
if (lastSignature && lastSignature !== data.signature) {
location.reload();
}
lastSignature = data.signature;
});
}, 3000);
The elegance of this approach: the server maintains no open connection. Every request is stateless. And the MD5 guarantees the page only reloads when there is a real change - not on every poll.
Why not WebSocket or SSE?
The project includes an SSE endpoint (/api/sse/updates.php) as a fallback, but polling remains the main solution for three reasons:
- Shared hosting - O2Switch does not guarantee long-lived connections
- Mobile resilience - a poll that fails simply retries on the next interval. An SSE that loses its connection has to handle reconnection
- 3 seconds is enough - we're not trading Bitcoin, this is rugby. A 3-second delay is imperceptible
Push notifications: FCM with Service Account
The spectator can subscribe to notifications for a match, a team, or an entire club. On every score, the backend sends a push via Firebase Cloud Messaging.
OAuth2 authentication
FCM API v1 requires an OAuth2 access token, not a simple API key. The backend generates a JWT signed with the Service Account's private key, exchanges it for an access token, and caches it for 1 hour:
// PushNotificationService.php - JWT pour FCM
$header = base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$payload = base64UrlEncode(json_encode([
'iss' => $serviceAccount['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => time(),
'exp' => time() + 3600
]));
openssl_sign("$header.$payload", $signature, $privateKey, OPENSSL_ALGO_SHA256);
$jwt = "$header.$payload." . base64UrlEncode($signature);
// Echange JWT contre access token
curl_post('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]);
The notification payload
Each notification includes the scores, the teams, and a deep link to the match:
$notification = [
'title' => "🏉 Essai !",
'body' => "10 - 7 • RCB vs BRC"
];
$data = [
'gameId' => '123',
'homeScore' => '10',
'awayScore' => '7',
'url' => '/club.php?id=5&game=123'
];
Three-level subscriptions
The SQL query that retrieves the tokens is the key to the system. A single query covers all three subscription levels:
SELECT DISTINCT FCMToken FROM PushSubscriptions
WHERE IsActive = TRUE
AND (
GameID = ? -- abonne a CE match
OR TeamID = ? -- abonne a CETTE equipe
OR (TeamID IS NULL AND (ClubID = ? OR ClubID = ?)) -- abonne au CLUB
)
A parent who subscribes to "Seniors" will receive notifications for all of that team's matches - no need to subscribe to each match individually.
The coach interface: a PWA on the sideline
The admin interface is designed to be used standing up, in the rain, with gloves on. It's a PWA (Progressive Web App) installable on the phone's home screen.
Team and match management
The coach (or the club admin) can:
- Create teams in bulk (one name per line in a textarea)
- Schedule matches with opponent autocomplete (search by club name, logo display)
- Start the match and advance the halves
Score live
The active match screen shows two columns (home team / away team) with three buttons each: Try (5 pts), Conversion (2 pts), Penalty (3 pts). Each tap:
- Adds the score to the DB
- Recalculates the total
- Triggers the push notification to all subscribers
An "Undo last action" button lets you correct a mistake - essential when you're distracted by the match.
Multi-club: one codebase, N clubs
V3 was built for a single club (BRC). V4 is multi-tenant : each club has its own space, teams, coaches, and public URL.
Roles
- Superadmin - manages all clubs, approves registrations, creates admins
- Club admin - manages the teams, matches, and coaches of their club
- Coach - only sees their team, can score live
Onboarding
A club that wants to join myrugby.be fills out a proposal form. The superadmin receives the request, approves it, creates the club, and generates the admin credentials. The club can then manage everything independently.
PWA: installable, offline-ready
The app is installable on iOS and Android via the manifest.json and a Service Worker that manages caching and offline mode:
- Manifest - icons, theme color, standalone display, portrait orientation
- Service Worker - cache-first for static assets, network-first for APIs
- Offline page - a dedicated page displays if the network drops
- FCM Service Worker - a separate worker manages receiving push notifications in the background
The result: the coach installs the app on their home screen, and gets a native experience without going through the App Store.
Project structure
Tech stack
- Backend - PHP 8.x, custom MVC, MySQL, PDO, native sessions
- Frontend - Tailwind CSS 4.1, vanilla JavaScript, no framework
- Real-time - 3s polling with MD5 signature + SSE as fallback
- Push - Firebase Cloud Messaging, API v1, Service Account with JWT
- PWA - Service Worker, manifest.json, offline page, FCM background worker
- Hosting - O2Switch (shared), deploy via FTP script
- Cost - 0 euros/month (existing hosting + free FCM)
What I learned
A few lessons from this project:
- Polling is underrated. With an MD5 signature, it becomes almost as responsive as WebSocket for this use case - and infinitely simpler to deploy on shared hosting.
- FCM API v1 is a real ordeal. The migration from the old API (simple API key) to Service Accounts with JWT and OAuth2 is well documented but full of subtleties. Caching the token is essential to avoid regenerating a JWT on every notification.
- PHP is not dead. For multi-tenant CRUD with auth, CSRF, sessions, and SQL, native PHP remains remarkably effective. Zero build step, zero node_modules, instant deployment via FTP.
- Coaches are not developers. The interface has to work with wet fingers, in the rain, during a match. Big buttons, immediate visual feedback, easy undo.