Projet

MyRugby.be - un livescore rugby multi-clubs en PHP, Tailwind et Firebase

Scores en direct, push notifications sur chaque essai, interface coach sur mobile, PWA installable. Un projet construit en décembre 2025 pour les clubs de rugby belges - sans framework JS, sans WebSocket, sans SaaS.

Ce projet a été entièrement construit avec l'IA en décembre 2025. PHP, JavaScript, SQL, Tailwind - tout le code a été génère par Claude Code. Mais l'architecture, les choix techniques et chaque décision de design sont les miens. 25 ans de dev web, ça sert à savoir quoi demander et quand dire non.

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.

MyRugby.be - Scores en direct de votre club
MyRugby.be - plateforme livescore pour clubs de rugby belges.

Vue d'ensemble

Le système tient en 4 couches :

  1. L'interface publique - un scoreboard temps réel par club, accessible sans login
  2. L'interface coach - une PWA installable pour gérer les matchs et scorer en direct
  3. Le backend PHP - un MVC maison avec MySQL et PDO
  4. Les push notifications - Firebase Cloud Messaging (FCM) avec Service Account
Spectateur (parent, fan) Coach (bord du terrain) myrugby.be/club?id=X myrugby.be/admin/ | | | polling /3s | POST score v v +------------------------------------------+ | PHP Backend (MVC) | | MySQL - PDO - Sessions - CSRF | +------------------------------------------+ | | | signature MD5 | sendScoreUpdate() v v Smart reload +---------------------+ (si state change) | FCM API v1 | | Service Account JWT | +---------------------+ | v Push notification sur le tel du parent

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 :

5Essai 2Transformation 3Pénalité

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 - bcrypt via password_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 :

  1. Ajoute le score en DB
  2. Recalcule le total
  3. 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

myrugby.be/ | |-- config/ | |-- constants.php Statuts, scores, rôles, Firebase | |-- database.php Singleton PDO + détection env | |-- src/ | |-- Models/ Club, Team, Game, Score, User | |-- Controllers/ AuthController (login, remember me) | |-- Services/ PushNotificationService (FCM) | |-- Utils/ Helpers (CSRF, escape, formatDate) | |-- public/ | |-- index.php Homepage (liste des clubs) | |-- club.php Scoreboard public + polling | |-- sw.js Service Worker (cache + offline) | |-- manifest.json PWA manifest | | | |-- admin/ Interface coach/admin | | |-- active_games.php Scoring en direct | | |-- games.php Créer/gerer les matchs | | |-- teams.php Gérer les équipes | | | |-- superadmin/ Gestion multi-clubs | | |-- clubs.php Tous les clubs | | |-- users.php Admins et coachs | | |-- proposals.php Demandes d'inscription | | | |-- api/ | |-- games/status.php Polling + signature MD5 | |-- sse/updates.php Server-Sent Events (fallback) | |-- notifications/ Subscribe/unsubscribe FCM

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.
Voir myrugby.be en direct