Mon club de rugby avait besoin d'un livescore. Les solutions existantes - FederationRugby, SportEasy - sont soit limitées, soit payantes, soit pas adaptées au rugby belge. J'ai décide de construire le mien : myrugby.be.
Le cahier des charges était simple : un coach sur le bord du terrain tapé "Essai" sur son téléphone, et les parents qui suivent de loin voient le score changer en direct. Avec une notification push sur chaque action.
Vue d'ensemble
Le système tient en 4 couches :
- L'interface publique - un scoreboard temps réel par club, accessible sans login
- L'interface coach - une PWA installable pour gérer 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 données
Le schéma MySQL est centre sur la relation Club → Team → Game → Score. Chaque club peut avoir plusieurs équipes (U16, U18, Seniors...), chaque équipe 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 états, gères 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 arrêts de jeu rendent tout chronomètre automatique inutilisable. Le match peut aussi être 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 colorés sont la signature visuelle de l'interface coach. En un coup d'oeil, on voit la décomposition 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) hérite de cette classe et ajoute ses méthodes spécifiques. Le Database utilise le pattern Singleton avec détection 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/.
Sécurité
- CSRF - token génère par session, vérifie sur chaque POST
- Passwords -
bcryptviapassword_hash()/password_verify() - SQL injection - requêtes préparées PDO partout
- XSS -
htmlspecialchars()sur toutes les sorties - Remember Me - token persistant en DB avec expiration 1 an
Le temps réel sans WebSocket
C'est la partie la plus interessante techniquement. Comment afficher des scores "en direct" sans WebSocket, sans Firebase, sans serveur Node ?
Réponse : du polling intelligent avec signature MD5.
Le principe
Toutes les 3 secondes, le navigateur du spectateur appelle une API qui retourne l'état actuel des matchs et un hash MD5 de cet état :
// 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()
]);
Côté client : reload conditionnel
Le JavaScript compare la signature avec la précédente. Si elle a changé, 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'élégance de cette approche : le serveur ne maintient aucune connexion ouverte. Chaque requête est stateless. Et le MD5 garantit que la page ne se recharge que quand il y à un vrai changement - pas à 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 :
- Hébergement partage - O2Switch ne garantit pas les connexions longues
- Résilience mobile - un poll qui échoué se relance au prochain interval. Un SSE qui perd sa connexion doit gérer la reconnexion
- 3 secondes suffisent - on ne trade pas du Bitcoin, c'est du rugby. Un délai de 3 secondes est imperceptible
Push notifications : FCM avec Service Account
Le spectateur peut s'abonner aux notifications d'un match, d'une équipe ou d'un club entier. À 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 génère un JWT signe avec la clé privée du Service Account, l'échange 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 équipes 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 Ă trois niveaux
La requête SQL qui récupère les tokens est la clé du système. 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 équipe - pas besoin de s'abonner à chaque match individuellement.
L'interface coach : PWA sur le bord du terrain
L'interface d'administration est pensée pour être utilisée debout, sous la pluie, avec des gants. C'est une PWA (Progressive Web App) installable sur l'écran d'accueil du téléphone.
Gestion des équipes et matchs
Le coach (oĂą l'admin du club) peut :
- Créer des équipes 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)
- Démarrer le match et avancer les mi-temps
Scorer en direct
L'écran de match actif affiche deux colonnes (équipe domicile / extérieur) avec trois boutons chacune : Essai (5 pts), Transformation (2 pts), Pénalité (3 pts). Chaque tap :
- Ajoute le score en DB
- Recalcule le total
- Déclenche le push notification vers tous les abonnes
Un bouton "Annuler la dernière action" permet de corriger une erreur - essentiel quand on est distrait par le match.
Multi-clubs : une seule codebase, N clubs
La V3 était construite pour un seul club (BRC). La V4 est multi-tenant : chaque club à son espace, ses équipes, ses coachs et son URL publique.
Les rĂ´les
- Superadmin - géré tous les clubs, approuve les inscriptions, crée les admins
- Admin club - géré les équipes, les matchs et les coachs de son club
- Coach - ne voit que son équipe, peut scorer en direct
L'onboarding
Un club qui veut rejoindre myrugby.be remplit un formulaire de proposition. Le superadmin reçoit la demande, la valide, crée le club et génère les identifiants admin. Le club peut ensuite gérer tout en autonomie.
PWA : installable, offline-ready
L'app est installable sur iOS et Android via le manifest.json et un Service Worker qui géré le cache et le mode offline :
- Manifest - icônes, thème color, display standalone, orientation portrait
- Service Worker - cache-first pour les assets statiques, network-first pour les API
- Offline page - une page dédiée s'affiche si le réseau tombe
- FCM Service Worker - un worker séparé géré la réception des push en background
Le résultat : le coach installe l'app sur son écran d'accueil, et il à une expérience 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 réel - 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
- Hébergement - O2Switch (mutualize), deploy via script FTP
- Coût - 0 euros/mois (hébergement existant + FCM gratuit)
Ce que j'ai appris
Quelques leçons tirées de ce projet :
- Le polling est sous-estime. Avec une signature MD5, il devient quasi aussi réactif que du WebSocket pour ce cas d'usage - et infiniment plus simple à déployer sur un hébergement 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 documentée mais pleine de subtilités. Le caching du token est essentiel pour ne pas re-générer un JWT à chaque notification.
- PHP n'est pas mort. Pour un CRUD multi-tenant avec auth, CSRF, sessions et SQL, PHP natif reste redoutablement efficace. Zéro build step, zéro node_modules, déploiement instantané 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 immédiat, annulation facile.