Les articles précédents décrivaient le monde d’en bas : l’extension C pour parler I2C, le rendu typographique sur des afficheurs LED, le moteur de règles en AST. 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 :
class Game < ActiveRecord::Base
has_many :game_participations, dependent: :destroy
# Scope par côté via des associations conditionnelles
has_many :hinge_participations,
-> { where(side: :hinge) },
class_name: "GameParticipation"
has_many :hinge_players,
class_name: "User",
through: :hinge_participations,
source: :user
has_many :lock_participations,
-> { where(side: :lock) },
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) { where("game_participations.side" => g.winning_side) },
class_name: "User",
through: :game_participations,
source: :user
has_many :losers,
->(g) { where.not("game_participations.side" => g.winning_side) },
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 :
scope :active, -> { where(cancelled_at: nil, finished_at: nil) }
scope :live, -> { active.started.not_finished }
scope :single, -> { where(game_participations_count: 2) }
scope :double, -> { where(game_participations_count: 4) }
scope :with_golden_goal, -> { where(score: [[7,6], [6,7]].map(&:to_yaml)) }
scope :with_5_0, -> { where(score: [[5,0], [0,5]].map(&:to_yaml)) }
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 :
class DeviceConfiguration < ActiveRecord::Base
serialize :start_game_messages
serialize :specific_game_rules
def self.fr_default_configuration
{
hinge_color: "blue",
lock_color: "red",
welcome_message: "WELCOME TO THE FOOSBALL SOCIETY",
rehearsal_message: "BALLE DE CHAUFFE",
start_game_messages: ["PRET ?", "JOUEZ "],
goal_message: "BUT",
inout_message: "GAMELLE",
tie_break_message: "TIE BREAK",
win_message: "VICTOIRE",
speed_unit: "km/h",
badge_message: "Badgez !"
}
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 :
# 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 :
# 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 :
# 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 :
# 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 :
# 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 :
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 :
# app/models/rating.rb
class Rating < ActiveRecord::Base
belongs_to :game_participation
scope :for_game_type, ->(game_type) { where(game_type: game_type) }
scope :for_event, ->(event) { where(event: event) }
scope :for_user, ->(user) {
joins(:game_participation).where(game_participations: { user_id: user.id })
}
# 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: { id: rating_ids })
.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 :
# 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_id}:game_events"
end
def redis_device_key(device_uuid)
"device:#{device_uuid}: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) :
# 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 -> { FeedsWorker.perform_async(id) }, 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 :
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 :
# 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 :
# 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 — 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.