Mon club de rugby avait besoin d'un livescore. Les solutions existantes — FederationRugby, SportEasy — sont soit limitees, soit payantes, soit pas adaptees au rugby belge. J'ai decide de construire le mien : myrugby.be.
Le cahier des charges etait simple : un coach sur le bord du terrain tape "Essai" sur son telephone, et les parents qui suivent de loin voient le score changer en direct. Avec une notification push sur chaque action.
Vue d'ensemble
Le systeme tient en 4 couches :
- L'interface publique — un scoreboard temps reel par club, accessible sans login
- L'interface coach — une PWA installable pour gerer les matchs et scorer en direct
- Le backend PHP — un MVC maison avec MySQL et PDO
- Les push notifications — Firebase Cloud Messaging (FCM) avec Service Account
Le modele de donnees
Le schema MySQL est centre sur la relation Club → Team → Game → Score. Chaque club peut avoir plusieurs equipes (U16, U18, Seniors...), chaque equipe joue des matchs, et chaque match accumule des 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)
Le cycle de vie d'un match
Un match passe par 5 etats, geres par une constante PHP :
define('GAME_STATUS', [
'NOT_STARTED' => 'Non commence',
'FIRST_HALF' => '1ere mi-temps',
'HALF_TIME' => 'Mi-temps',
'SECOND_HALF' => '2eme mi-temps',
'FINISHED' => 'Termine'
]);
Le coach fait avancer le statut manuellement — pas de timer automatique. Au rugby, les arrets de jeu rendent tout chronometre automatique inutilisable. Le match peut aussi etre mis en pause (blessure, carton), et le temps de pause est soustrait du chrono affiche.
Les types de score
Trois actions possibles, chacune avec ses points :
Ces cercles colores sont la signature visuelle de l'interface coach. En un coup d'oeil, on voit la decomposition du score — pas juste le total.
Le backend PHP : MVC sans framework
Pas de Laravel, pas de Symfony. Un MVC maison avec une classe Model abstraite qui fournit le CRUD de base :
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 { ... }
}
Chaque modele (Club, Team, Game, Score, User) herite de cette classe et ajoute ses methodes specifiques. Le Database utilise le pattern Singleton avec detection d'environnement automatique (local vs production).
Pourquoi pas un framework ?
Pour un projet de cette taille (~1 700 lignes de backend), un framework serait du over-engineering. Les besoins sont simples : routing basique (un fichier PHP par page), queries SQL directes, sessions natives. Le MVC maison fait le job sans les 50 Mo de vendor/.
Securite
- CSRF — token genere par session, verifie sur chaque POST
- Passwords —
bcryptviapassword_hash()/password_verify() - SQL injection — requetes preparees PDO partout
- XSS —
htmlspecialchars()sur toutes les sorties - Remember Me — token persistant en DB avec expiration 1 an
Le temps reel sans WebSocket
C'est la partie la plus interessante techniquement. Comment afficher des scores "en direct" sans WebSocket, sans Firebase, sans serveur Node ?
Reponse : du polling intelligent avec signature MD5.
Le principe
Toutes les 3 secondes, le navigateur du spectateur appelle une API qui retourne l'etat actuel des matchs et un hash MD5 de cet etat :
// 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()
]);
Cote client : reload conditionnel
Le JavaScript compare la signature avec la precedente. Si elle a change, la page se recharge. Sinon, rien ne se passe :
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);
L'elegance de cette approche : le serveur ne maintient aucune connexion ouverte. Chaque requete est stateless. Et le MD5 garantit que la page ne se recharge que quand il y a un vrai changement — pas a chaque poll.
Pourquoi pas WebSocket ou SSE ?
Le projet inclut un endpoint SSE (/api/sse/updates.php) comme fallback, mais le polling reste la solution principale pour trois raisons :
- Hebergement partage — O2Switch ne garantit pas les connexions longues
- Resilience mobile — un poll qui echoue se relance au prochain interval. Un SSE qui perd sa connexion doit gerer la reconnexion
- 3 secondes suffisent — on ne trade pas du Bitcoin, c'est du rugby. Un delai de 3 secondes est imperceptible
Push notifications : FCM avec Service Account
Le spectateur peut s'abonner aux notifications d'un match, d'une equipe ou d'un club entier. A chaque score, le backend envoie un push via Firebase Cloud Messaging.
L'authentification OAuth2
FCM API v1 exige un access token OAuth2, pas une simple API key. Le backend genere un JWT signe avec la cle privee du Service Account, l'echange contre un access token, et le met en cache pendant 1 heure :
// 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
]);
Le payload de notification
Chaque notification inclut les scores, les equipes et un deep link vers le match :
$notification = [
'title' => "🏉 Essai !",
'body' => "10 - 7 • RCB vs BRC"
];
$data = [
'gameId' => '123',
'homeScore' => '10',
'awayScore' => '7',
'url' => '/club.php?id=5&game=123'
];
Abonnements a trois niveaux
La requete SQL qui recupere les tokens est la cle du systeme. Un seul query couvre les trois niveaux d'abonnement :
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
)
Un parent qui s'abonne aux "Seniors" recevra les notifications de tous les matchs de cette equipe — pas besoin de s'abonner a chaque match individuellement.
L'interface coach : PWA sur le bord du terrain
L'interface d'administration est pensee pour etre utilisee debout, sous la pluie, avec des gants. C'est une PWA (Progressive Web App) installable sur l'ecran d'accueil du telephone.
Gestion des equipes et matchs
Le coach (ou l'admin du club) peut :
- Creer des equipes en bulk (un nom par ligne dans un textarea)
- Programmer des matchs avec autocompletion de l'adversaire (recherche par nom de club, affichage du logo)
- Demarrer le match et avancer les mi-temps
Scorer en direct
L'ecran de match actif affiche deux colonnes (equipe domicile / exterieur) avec trois boutons chacune : Essai (5 pts), Transformation (2 pts), Penalite (3 pts). Chaque tap :
- Ajoute le score en DB
- Recalcule le total
- Declenche le push notification vers tous les abonnes
Un bouton "Annuler la derniere action" permet de corriger une erreur — essentiel quand on est distrait par le match.
Multi-clubs : une seule codebase, N clubs
La V3 etait construite pour un seul club (BRC). La V4 est multi-tenant : chaque club a son espace, ses equipes, ses coachs et son URL publique.
Les roles
- Superadmin — gere tous les clubs, approuve les inscriptions, cree les admins
- Admin club — gere les equipes, les matchs et les coachs de son club
- Coach — ne voit que son equipe, peut scorer en direct
L'onboarding
Un club qui veut rejoindre myrugby.be remplit un formulaire de proposition. Le superadmin recoit la demande, la valide, cree le club et genere les identifiants admin. Le club peut ensuite gerer tout en autonomie.
PWA : installable, offline-ready
L'app est installable sur iOS et Android via le manifest.json et un Service Worker qui gere le cache et le mode offline :
- Manifest — icones, theme color, display standalone, orientation portrait
- Service Worker — cache-first pour les assets statiques, network-first pour les API
- Offline page — une page dediee s'affiche si le reseau tombe
- FCM Service Worker — un worker separe gere la reception des push en background
Le resultat : le coach installe l'app sur son ecran d'accueil, et il a une experience native sans passer par l'App Store.
Structure du projet
Stack technique
- Backend — PHP 8.x, MVC maison, MySQL, PDO, sessions natives
- Frontend — Tailwind CSS 4.1, vanilla JavaScript, pas de framework
- Temps reel — polling 3s avec signature MD5 + SSE en fallback
- Push — Firebase Cloud Messaging, API v1, Service Account avec JWT
- PWA — Service Worker, manifest.json, offline page, FCM background worker
- Hebergement — O2Switch (mutualize), deploy via script FTP
- Cout — 0 euros/mois (hebergement existant + FCM gratuit)
Ce que j'ai appris
Quelques lecons tirees de ce projet :
- Le polling est sous-estime. Avec une signature MD5, il devient quasi aussi reactif que du WebSocket pour ce cas d'usage — et infiniment plus simple a deployer sur un hebergement mutualise.
- FCM API v1 est un parcours du combattant. La migration depuis l'ancienne API (simple API key) vers les Service Accounts avec JWT et OAuth2 est bien documentee mais pleine de subtilites. Le caching du token est essentiel pour ne pas re-generer un JWT a chaque notification.
- PHP n'est pas mort. Pour un CRUD multi-tenant avec auth, CSRF, sessions et SQL, PHP natif reste redoutablement efficace. Zero build step, zero node_modules, deploiement instantane par FTP.
- Les coachs ne sont pas des devs. L'interface doit marcher avec des doigts mouilles, sous la pluie, en plein match. Gros boutons, feedback visuel immediat, annulation facile.