# Le Rails derrière le baby-foot > Comment on a structuré une application Rails 4 pour gérer des baby-foots connectés, du badge RFID au classement ELO — avec une API JSON, du temps réel Redis et un système de trophées. Date : 15/09/2014 Auteur : Raphael P. Tags : Ruby, Rails, Architecture, API, Redis --- Les articles précédents décrivaient le monde d'en bas : l'[extension C pour parler I2C](/blog/extension-c-ruby-embarque-i2c), le [rendu typographique sur des afficheurs LED](/blog/affichage-texte-led-custom-ruby-freetype), le [moteur de règles en AST](/blog/moteur-regles-baby-foot-ast-ruby). Mais tout ça n'a de sens que si un serveur central orchestre le tout : stocker les parties, calculer les classements, gérer les joueurs, diffuser les événements en temps réel. C'est le rôle du **Rails**. Voici comment on l'a architecturé. ## Vue d'ensemble L'application `TekbakWebfront` est un Rails 4.2 classique, déployé sur Heroku avec Puma en serveur web et Sidekiq pour les jobs asynchrones. Elle sert trois types de clients très différents : Les **baby-foots** communiquent en JSON pur via l'API v1 — enregistrer les joueurs, signaler les buts, récupérer la configuration. Les **apps mobiles** utilisent la même API avec un token d'authentification. Le **site web** sert des vues ERB classiques avec Bootstrap et CoffeeScript. La logique métier est commune à tous, mutualisée dans les modèles et les services. Le Gemfile est conséquent : Devise pour l'authentification, Rolify pour les rôles, PgSearch pour la recherche full-text, CarrierWave pour les uploads S3, Sidekiq pour les jobs, RABL et Jbuilder pour la sérialisation JSON, WickedPDF pour les exports PDF, ActiveAdmin pour le back-office. Plus Koala pour Facebook et LinkedIn OAuth pour l'inscription sociale. C'est un Rails monolithique assumé — tout est dans la même app. ## Le modèle de données Le domaine est riche : joueurs, badges RFID, parties, événements (tournois), trophées, classement ELO, feed social. Voici le cœur : Le pivot central est **`GameParticipation`** — la table de jointure entre `User` et `Game`. Elle porte le côté (hinge ou lock), la position (avant ou arrière), et rattache les `Rating` et le `Badge` RFID du joueur. C'est grâce à ce modèle qu'on peut interroger les gagnants, les perdants, les adversaires : ```ruby class Game < ActiveRecord::Base has_many :game_participations, dependent: :destroy # Scope par côté via des associations conditionnelles has_many :hinge_participations, -> , class_name: "GameParticipation" has_many :hinge_players, class_name: "User", through: :hinge_participations, source: :user has_many :lock_participations, -> , class_name: "GameParticipation" has_many :lock_players, class_name: "User", through: :lock_participations, source: :user # Gagnants et perdants — dynamiques selon winning_side has_many :winners, ->(g) , class_name: "User", through: :game_participations, source: :user has_many :losers, ->(g) , class_name: "User", through: :game_participations, source: :user # Le score est un Array sérialisé : [score_hinge, score_lock] serialize :score, Array SIDES = %w(hinge lock).freeze POSITIONS = %w(front back).freeze end ``` Le score stocké comme un `Array` sérialisé est un choix pragmatique : un `[5, 3]` pour hinge 5, lock 3. La logique de score vit dans `Foosball::GameLogic`, pas dans le modèle. Les scopes permettent de filtrer finement les parties : ```ruby scope :active, -> scope :live, -> scope :single, -> scope :double, -> scope :with_golden_goal, -> scope :with_5_0, -> ``` On notera le `map(&:to_yaml)` — puisque le score est sérialisé en YAML dans la colonne, il faut comparer avec la forme sérialisée. C'est un peu laid, mais ça évite de dénormaliser le score dans des colonnes séparées. ## L'API v1 : le contrat avec la table Le baby-foot ne connaît pas Rails. Il connaît une API JSON. Le cycle de vie d'une partie vu depuis la table : Quand la table se connecte, elle appelle `POST /register` et reçoit sa `DeviceConfiguration` — messages d'accueil, couleurs des côtés, règles spécifiques. La configuration gère le multilingue (FR/EN) et les messages affichés sur l'écran LED : ```ruby class DeviceConfiguration < ActiveRecord::Base serialize :start_game_messages serialize :specific_game_rules def self.fr_default_configuration end end ``` Ensuite, les joueurs posent leurs badges RFID. Le badge est un hex de 8 caractères — libre, révoqué ou expiré. C'est le premier rempart : si le badge n'est pas valide, le joueur ne peut pas s'inscrire. Quand les deux côtés sont prêts, la table envoie `POST /kick_off` : ```ruby # app/models/game.rb — lancement de la partie def kick_off return false unless logic.can_start_game? start # started_at = DateTime.now, device.in_use! TimeoutWorker.schedule_timeout(id) if logic.has_rehearsal_period? update_score(0, 0) # période de chauffe, buts non comptés else hinge_score, lock_score = logic.initial_score update_score(hinge_score, lock_score) end true end ``` Pendant la partie, chaque but et chaque inout est un `POST` avec un `side` et une `speed`. Le `GamesController` orchestre le traitement : ```ruby # GamesController — appelé par POST /goal et POST /inout def process_event(klass_event) parameters = event_params parameters[:game_id] = @game.id if @game # Persiste l'événement (Goal, InOut ou GoalCancellation via STI) @event = klass_event.create(parameters) # Rejoue tous les événements pour recalculer le score @game.process_game_event(@event) if @game.should_finish? # Delay de 4.5s — le temps que l'animation LED de victoire se joue FinishGameWorker.perform_in(4.5.seconds, @game.id) end end ``` Le `GameEvent` utilise STI — `Goal`, `InOut` et `GoalCancellation` héritent tous du même modèle. Chaque événement porte un `side` (hinge/lock), un `type`, et optionnellement une `speed` (mesurée par le piézo) et un flag `from_back` (tir de l'arrière). Le `process_game_event` délègue à `Foosball::Logic` qui rejoue tous les événements pour recalculer le score — un pattern event-sourcing léger. ## La gestion des erreurs dans l'API L'API utilise un concern `Failure` qui déclare les types d'erreur et leur code HTTP au niveau du controller. Un `error!(:not_running_game)` retourne automatiquement un `422` avec un JSON structuré — plus propre que des `render status: 422` disséminés partout : ```ruby # app/controllers/api/v1/api_controller.rb class Api::V1::ApiController < ActionController::Base include Failure include UserLoading failure_type :unauthorized, 403 failure_type :invalid_credentials, 403 failure_type :not_found, 404 failure_type :not_running_game, 422 failure_type :incorrect_side, 422 end ``` ## Authentification : badges et tokens Le modèle d'auth est double. La **table** est identifiée par son UUID sur un réseau privé, sans token. Les **joueurs** s'authentifient par badge RFID sur la table, et par token Bearer sur l'API mobile. Un concern `UserLoading` extrait le Bearer du header `Authorization` : ```ruby # app/controllers/concerns/user_loading.rb module UserLoading extend ActiveSupport::Concern included do attr_reader :current_user before_action :authenticate_user_from_token end def authenticate_user_from_token error!(:invalid_credentials) unless access_token @current_user = User.find_by_access_token(access_token) error!(:invalid_credentials) unless @current_user end private def access_token raw = request.headers["Authorization"] raw =~ /^Bearer (.*)$/ ? $1 : nil end end ``` Le token est généré par Devise et stocké sous forme de digest SHA1. Le logout ne supprime pas le token — il en génère un nouveau, ce qui invalide l'ancien. ## Le classement ELO Chaque fin de partie déclenche un recalcul de rating. Le système n'est pas un vrai ELO (on n'utilise pas la formule K-factor classique), mais le principe est le même : un score numérique, mis à jour après chaque partie, séparé par type (simple / double). La formule de rating est volontairement simple : ```ruby # GameParticipation — calcul du nouveau rating après une partie def update_rating_for_event(event, with_scheduling = true) previous_rating = Rating.user_rating_score(user, game_type, event) previous_rating ||= 1000 # score de départ pour les nouveaux joueurs new_rating = previous_rating new_rating += 5 # +5 pour avoir joué new_rating += 5 if has_won? # +5 pour la victoire # Delta de score : le gagnant 5-0 prend +5, le perdant 0-5 prend -5 hinge_score, lock_score = game.score new_rating += side.to_s == "hinge" ? hinge_score - lock_score : lock_score - hinge_score # Le bonus trophées s'ajoute au rating global, pas au rating event create_rating(new_rating, user.trophies_bonus, event) end ``` Gagner 5-0 donne donc +5 (joué) +5 (victoire) +5 (delta) = +15. Perdre 3-5 donne +5 (joué) +0 (défaite) -2 (delta) = +3. Tout le monde monte, mais les bons montent plus vite. Ce n'est pas académiquement correct, mais c'est fun — personne ne veut voir son score baisser. Le classement est **dénormalisé** dans la table `users` : plutôt que de calculer le rang à chaque lecture, on le pré-calcule après chaque partie. Le `RecomputeRankingsWorker` utilise une window function PostgreSQL : ```ruby class RecomputeRankingsWorker include Sidekiq::Worker sidekiq_options queue: :recompute_rankings, retry: 1 def perform(game_type, genders) User.recompute_rankings(game_type, genders) rescue ActiveRecord::StatementInvalid # Deux workers concurrents → l'un échoue sur le lock, pas grave logger.info "detected concurrent rankings update, skipping" end end ``` Le `rescue` vide est volontaire — si deux workers tentent de recalculer en même temps, l'un échoue, pas grave, le suivant rattrapera. Les rankings sont recalculés via du SQL brut avec `RANK() OVER (ORDER BY cached_rating DESC)`, et chaque utilisateur reçoit son `cached_ranking_single` et `cached_ranking_position_single` mis à jour. Le modèle `Rating` est aussi capable de fournir un classement scopé par événement — pour les tournois d'entreprise, chaque `Event` a son propre classement indépendant du global : ```ruby # app/models/rating.rb class Rating < ActiveRecord::Base belongs_to :game_participation scope :for_game_type, ->(game_type) scope :for_event, ->(event) scope :for_user, ->(user) { joins(:game_participation).where(game_participations: ) } # Classement global ou scopé par événement (tournois) def self.users_ratings_with_rankings_for(game_type, event = nil) rating_ids = latest_ratings_for(game_type, event).map(&:id) User.joins(game_participations: :ratings) .where(ratings: ) .select("users.*, ratings.rating AS event_rating, RANK() OVER (ORDER BY ratings.rating DESC) AS event_ranking") end end ``` ## Le temps réel avec Redis Quand un but est marqué, l'app mobile et le site web doivent le savoir immédiatement. On utilise Redis pub/sub : Le `RedisClient` tient en 25 lignes. Chaque événement de jeu est publié sur un channel dédié, et les enregistrements de joueurs sur un channel par device : ```ruby # lib/redis_client.rb — 25 lignes, toute la couche pub/sub class RedisClient def publish_game_event(game_event) payload = game_event.serialize_with_game[:game].to_json publish redis_game_key(game_event.game_id), payload end def register_players(device, game) payload = game.registration_serialization.to_json publish redis_device_key(device.uuid), payload end private def publish(channel, message) Redis.current.publish(channel, message) end def redis_game_key(game_id) "game:#:game_events" end def redis_device_key(device_uuid) "device:#:device_events" end end ``` Les clients mobiles et web s'abonnent via un service WebSocket séparé. Le payload contient le score mis à jour et le dernier événement — suffisant pour que le client rafraîchisse son interface sans re-fetcher l'état complet. Côté web, on utilise la librairie Timeline.js (vis.js) pour afficher en temps réel la chronologie des buts sur la page de la partie. ## Le système de feed social On a voulu un feed façon réseau social — quand tu finis une partie, tes supporters voient le résultat dans leur timeline. Le modèle `Feed` est polymorphe, avec STI pour les sous-types (`GameFinished`, `RankUp`, `InOut`, `Sponsored`) : ```ruby # app/models/feed.rb — base STI pour le feed social class Feed < ActiveRecord::Base belongs_to :target, polymorphic: true has_many :user_feeds, dependent: :destroy after_commit -> , on: :create def broadcast broadcasters.each do |player| player.own_feeds << self unless player.feeds.include?(self) end return unless broadcast_to_supporters? broadcasters.each do |player| player.supporters.each do |supporter| supporter.external_feeds << self unless supporter.feeds.include?(self) end end end end ``` Les sous-classes ne définissent que `broadcasters` et `broadcast_to_supporters?`. Un `GameFinished` broadcast aux joueurs et leurs supporters, un `RankUp` au joueur seul. Le `FeedsWorker` fait une ligne : `Feed.find(feed_id).broadcast`. La logique vit dans le modèle, le worker n'est qu'un déclencheur asynchrone. ## Les 12 trophées Le système de trophées est un levier d'engagement. Chaque joueur a 12 trophées, chacun avec 5 niveaux et des seuils configurables en YAML : La config des seuils vit dans `config/trophies.yml` — pas en base. Un extrait : ```yaml ko: levels_treshold: [1, 10, 50, 100] rating_bonus: [10, 50, 100, 200] shuttle: levels_treshold: [5, 10, 20, 50] rating_bonus: [10, 50, 100, 200] member: rating_bonus: [10, 50] ``` Le `TrophiesService::ForGame` analyse le résultat et attribue les trophées : victoire 5-0 → "ko", golden goal en prolongation → "golden_goal", premier buteur → "the_first". Les trophées de vitesse ("balloon", "aircraft", "jet_plane", "shuttle") sont basés sur la mesure du piézo dans la cage de but — c'est la seule métrique physique qui remonte jusqu'aux trophées, et c'est un des trucs que les joueurs adorent comparer. ## Les workers Sidekiq La moitié de la logique métier tourne en asynchrone. Le pattern est uniforme — des workers très courts qui délèguent tout au modèle. Le plus critique : ```ruby # app/workers/finish_game_worker.rb class FinishGameWorker include Sidekiq::Worker sidekiq_options queue: :finish_game, backtrace: true def perform(game_id) game = Game.find(game_id) return unless game.should_finish? game.finish # Publie sur Redis pour que les apps mobiles affichent le résultat event = GameFinishedRequested.new(game, game.winning_side) RedisClient.new.publish_game_event(event) end end ``` Les deux workers de timeout suivent le même schéma : `TimeoutWorker` annule une partie qui dure trop longtemps, `RegistrationTimeoutWorker` annule une partie qui n'a jamais démarré (les joueurs ont badgé mais n'ont pas lancé le kick-off). Les deux utilisent `perform_at` pour se planifier au moment du timeout configuré. Le `FinishGameWorker` est le plus critique. Le delay de 4.5 secondes est volontaire : il laisse le temps au baby-foot d'afficher l'animation de victoire sur l'écran LED avant que le serveur publie l'événement de fin sur Redis. Sans ce délai, l'app mobile afficherait le résultat avant que les joueurs ne le voient sur la table. C'est un détail, mais c'est le genre de détail qui fait la différence entre un produit poli et un prototype. Les deux workers de timeout gèrent des cas différents : `TimeoutWorker` annule une partie qui dure trop longtemps (configurable par `GameConfiguration`), et `RegistrationTimeoutWorker` annule une partie qui n'a jamais démarré — les joueurs ont badgé mais n'ont pas lancé le kick-off. Sans ça, un device peut rester bloqué sur une partie fantôme indéfiniment. ## La logique de jeu isolée Un choix architectural qu'on ne regrette pas : la logique de jeu est **complètement isolée** du reste de Rails dans `lib/foosball/` : `Foosball::Logic` est un wrapper autour de `GameLogic`. Le modèle `Game` lui délègue toute la logique via `delegate`. Le wrapper rejoue les événements à chaque appel pour recalculer l'état : ```ruby # lib/foosball/logic.rb — event-sourcing léger module Foosball class Logic extend Forwardable def_delegator :@game, :rules def initialize(game) @game = game end # Ajoute un événement et recalcule le score en rejouant tout def process_game_event(event) @game.add_game_event(event) replay_events @game.update_score(*current_score[0..1]) end private # Rejoue tous les événements depuis le début pour recalculer l'état def replay_events @game.game_events.in_order.each do |event| side = event.side.to_sym case event.type when "Goal" then game_logic.goal(side) when "InOut" then game_logic.inout(side) when "GoalCancellation" then game_logic.goal_cancellation(side) end end end def game_logic @game_logic ||= GameLogic.new(@game.rules) end end end ``` C'est la même `GameLogic` qui tourne sur le BeagleBone (côté embarqué) et sur le serveur Rails — les **mêmes règles** des deux côtés. La classe est même configurable par variable d'environnement (`GAME_LOGIC_CLASS`), ce qui permet de brancher des implémentations différentes en test ou pour des variations de règles. Un `Foosball::GameStatistics` complète le tableau — un objet sérialisable qui maintient les compteurs victoire/défaite par adversaire. Il implémente `dump`/`load` pour être utilisé comme sérialiseur custom avec `serialize :statistics_single, Foosball::GameStatistics` dans le modèle `User`. C'est comme ça qu'on stocke les stats détaillées sans exploser le nombre de tables. C'est ce que décrit l'[article sur le moteur de règles](/blog/moteur-regles-baby-foot-ast-ruby) — l'évaluateur AST, la simulation des coups suivants, le verrouillage IBR. Tout ça vit dans `GameLogic`, sans dépendance à ActiveRecord. ## Le support social Un mini réseau social s'est greffé naturellement. Les joueurs peuvent "supporter" d'autres joueurs — un follow asymétrique avec un modèle `Support` qui relie deux `User`. Le module `Notifiable` crée automatiquement une notification quand un support est créé. C'est ce qui alimente le feed — quand tu finis une partie, tes supporters voient le résultat. C'est aussi ce qui donne de la profondeur aux classements : on ne joue plus seulement pour soi, on joue devant un public. ## Ce qu'on a appris **Rails est un excellent choix pour ce type de projet.** Un baby-foot connecté, c'est fondamentalement un CRUD avec du temps réel par-dessus. Rails excelle là-dedans. Le routing, les validations, les associations, les migrations — tout ce plumbing qu'on n'a pas eu à écrire nous a fait gagner des semaines. **Sidekiq est indispensable.** Sans workers asynchrones, on aurait dû choisir entre la latence API (critique pour la table) et la richesse du post-traitement (trophées, stats, feeds). Avec Sidekiq, on n'a pas à choisir. **Les concerns, c'est bien mais c'est piégeux.** Le modèle `User` avec ses 6 concerns (`UserStatistics`, `UserLegacyStatistics`, `UserWeekStatistics`, `UserMonthStatistics`, `UserBadges`, `TokenAuthenticable`) fonctionne — mais quand on ouvre le fichier, on ne voit pas d'où viennent la moitié des méthodes. C'est une dette technique qu'on assume pour l'instant. **Le modèle de données est le vrai produit.** Les tables, les apps mobiles, le site web — ce sont des clients. La valeur est dans le schéma PostgreSQL et la logique métier Ruby. Si demain on devait réécrire le frontend ou changer de hardware, le Rails resterait. Pour l'instant, ça tourne. Les premières tables sont en production dans des bars et des entreprises. Les joueurs scannent leurs badges, les buts s'affichent, les classements se mettent à jour, les trophées se débloquent. Un `git push heroku main` et c'est en prod. C'est exactement ce qu'on voulait.