Dans les deux articles précédents, on a expliqué comment on a écrit une extension C pour parler I2C depuis Ruby, puis comment on a recodé un pipeline de rendu typographique pour afficher du texte sur des écrans LED de 7 pixels de haut. Mais il manque la pièce centrale : comment le baby-foot décide qu’un but est valide, qu’une partie est gagnée, ou que la balle doit être verrouillée.
Le client — Tekbak / Foosball Society — ne voulait pas un seul jeu de règles codé en dur. Chaque table est déployée dans un contexte différent (bar, événement d’entreprise, tournoi), et les règles changent : score à atteindre, gestion du tie-break, timeout, valeur d’un goal vs un inout. Il fallait un moteur de règles configurable, envoyé par le serveur au démarrage de chaque partie.
Le problème : des règles qui changent à chaque partie
Prenons un exemple concret. En mode “bar”, une partie standard :
- Le premier à 5 points avec 2 points d’écart gagne
- Si personne n’y arrive, à 10 points on passe en tie-break
- En tie-break, 13 points = victoire automatique
- Un timeout à 15 minutes déclenche un départage au score
En mode “tournoi”, c’est différent : pas de timeout, tie-break à 7, goal de l’extérieur qui vaut double. Et le client voulait pouvoir inventer de nouvelles variantes sans toucher au code embarqué.
On avait besoin d’un format de règles :
- Déclaratif — pas de code Ruby dans la config
- Composable — des conditions qu’on peut imbriquer
- Sérialisable — envoyé en JSON via RabbitMQ depuis le serveur
- Évaluable — interprété par le referee embarqué sur le BeagleBone
L’inspiration MongoDB
La syntaxe de requêtes MongoDB — des documents JSON avec des opérateurs $gt, $and, $or — est devenue un idiome familier pour exprimer des conditions composables dans un format de données. On s’en est directement inspiré.
Une règle de victoire “5 points avec 2 d’écart” s’exprime :
win_rule: {
:$and => [
{ :$geq => [ :$score, 5 ] },
{ :$geq => [ :$score_delta, 2 ] }
]
}
C’est un arbre syntaxique (AST) encodé dans une structure Ruby native (Hash + Array + Symbol). Chaque nœud est soit un opérateur ($and, $geq, $or…), soit une variable ($score, $other_score…), soit une valeur littérale (5, 2, true…).
Les opérateurs
Le moteur comprend 7 opérateurs, divisés en deux familles :
Comparaisons — prennent deux arguments et retournent un booléen :
| Opérateur | Sémantique |
|---|---|
$eq | Tous les arguments sont égaux |
$gt | Premier argument strictement supérieur |
$geq | Premier argument supérieur ou égal |
$lt | Premier argument strictement inférieur |
$leq | Premier argument inférieur ou égal |
Logiques — combinent des sous-expressions :
| Opérateur | Sémantique |
|---|---|
$and | Toutes les sous-expressions sont vraies |
$or | Au moins une sous-expression est vraie |
Et 4 variables injectées au moment de l’évaluation, en fonction du côté évalué (hinge ou lock) :
| Variable | Valeur |
|---|---|
$score | Score de l’équipe évaluée |
$other_score | Score de l’adversaire |
$score_delta | Écart absolu entre les deux scores |
$max_score | Score le plus élevé des deux |
L’évaluateur récursif
L’évaluateur fait 40 lignes. C’est une descente récursive classique sur la structure de données :
def run_rule(side, scores, rule)
return false if rule.nil?
if rule.kind_of?(Hash)
# nœud opérateur : { $op => [arg1, arg2, ...] }
operator, args = rule.first
values = args.collect { |x| run_rule(side, scores, x) }
case operator.to_sym
when :$eq then values.all? { |x| x == values[0] }
when :$gt then values[0] > values[1]
when :$geq then values[0] >= values[1]
when :$lt then values[0] < values[1]
when :$leq then values[0] <= values[1]
when :$and then values.all?
when :$or then values.any?
end
elsif rule.to_s.start_with?('$')
# feuille variable : résolution contextuelle
other = other_side(side)
case rule.to_sym
when :$score then scores[side]
when :$other_score then scores[other]
when :$score_delta then (scores[side] - scores[other]).abs
when :$max_score then [scores[side], scores[other]].max
end
else
# feuille littérale : 5, 2, true...
rule
end
end
Le point clé : chaque opérateur évalue récursivement ses arguments avant d’appliquer sa logique. $and appelle run_rule sur chaque sous-expression, qui peut elle-même contenir un $or imbriqué, etc. C’est un interpréteur d’arbre au sens classique du terme.
Et comme le side est passé en paramètre, la même règle peut être évaluée pour les deux côtés : run_rule(:hinge, scores, win_rule) et run_rule(:lock, scores, win_rule) donnent des résultats différents parce que $score et $other_score sont inversés.
La configuration complète d’une partie
Le serveur envoie un JSON au démarrage de chaque partie via RabbitMQ. La structure complète :
rules: {
timeout_s: 900,
rehearsal_balls: 0,
initial_score: { hinge: 0, lock: 0 },
goal_strength: {
default: {
hinge: { goal: [1, 0], inout: [1, 0] },
lock: { goal: [1, 0], inout: [1, 0] }
},
tie_break: {
hinge: { goal: [1, 0], inout: [1, 0] },
lock: { goal: [1, 0], inout: [1, 0] }
}
},
conditions: {
default: {
win_rule: {
:$and => [
{ :$geq => [:$score, 5] },
{ :$geq => [:$score_delta, 2] }
]
}
},
tie_break: {
start_rule: { :$geq => [:$max_score, 5] },
win_rule: {
:$or => [
{ :$geq => [:$score, 7] },
{ :$and => [
{ :$geq => [:$score, :$other_score] },
{ :$geq => [:$score_delta, 2] }
]}
]
}
},
timeout: {
win_rule: { :$geq => [:$score, :$other_score] }
}
}
}
Trois phases de jeu, chacune avec ses propres règles :
- default — le jeu normal. Ici : 5 points avec 2 d’écart.
- tie_break — déclenché quand
start_ruleest vrai (max score ≥ 5). Règle de victoire plus souple : 7 points direct, ou 2 d’écart. - timeout — si la partie dépasse 15 minutes, celui qui mène gagne.
Le goal_strength définit combien de points chaque type d’action rapporte (ou enlève). [1, 0] signifie +1 pour le buteur, 0 pour l’adversaire. On peut imaginer [2, -1] pour un mode agressif où un goal “de l’extérieur” (côté lock) vaut double et retire un point à l’adversaire.
La simulation : prédire le prochain coup
C’est la partie la plus intéressante du moteur. À chaque but, le referee doit décider si l’IBR (le mécanisme de remise en jeu) doit être verrouillé ou libéré de chaque côté. La règle : si le prochain coup de ce côté peut déclencher une fin de partie, on verrouille.
Pour ça, on simule tous les scénarios possibles du prochain coup :
def potential_winners_with_next_goal
winners = Set.new
%i{hinge lock}.product(%i{goal inout}) do |side, goal_type|
# copier le score actuel
scores = @score.dup
# simuler le coup
run_score_update(scores, side, goal_type)
# évaluer les règles de victoire sur le score simulé
winner = compute_winner(scores, side, current_rules)
if winner
winners.add(winner)
end
end
winners.to_a
end
On itère sur le produit cartésien de {hinge, lock} × {goal, inout} — 4 scénarios au total. Pour chacun, on copie le score, on applique le goal_strength, puis on évalue les règles de victoire et de défaite. Si un scénario produit un gagnant, ce gagnant entre dans l’ensemble des “winners potentiels”.
Ensuite, la décision IBR est triviale :
def update_ibr_states
potential_winners = potential_winners_with_next_goal
%i{hinge lock}.each do |side|
if potential_winners.include?(side)
lock_ibr(side) # → écriture I2C 0x51, 0x00
else
release_ibr(side) # → écriture I2C 0x51, 0x01
end
end
end
Si un côté pourrait gagner au prochain coup — que ce soit par goal ou par inout — on verrouille sa balle. Si le côté ne peut pas gagner, on libère. C’est de la théorie des jeux appliquée à un solénoïde.
Le cas subtil du tie-break
La simulation tient aussi compte des transitions de phase. Si le score actuel est 4-4 et qu’un goal hinge donnerait 5-4, le moteur vérifie :
- Est-ce que
win_ruledefault (5 pts, 2 d’écart) est remplie ? Non, écart = 1. - Est-ce que
start_ruletie-break ($max_score >= 5) est remplie ? Oui. - On bascule en tie-break. Les règles changent.
Le current_rules bascule dynamiquement :
def current_rules
if @game_timed_out
@timeout_rules
elsif @is_tie_break
@tie_break_rules
else
@default_rules
end
end
Et le goal_strength aussi — en tie-break, les inouts ne rapportent plus de points au buteur (seulement une pénalité à l’adversaire). Ça change complètement la dynamique du jeu.
Pourquoi un AST et pas du code ?
On aurait pu écrire les règles en Ruby et les eval. On aurait pu utiliser des lambdas sérialisées. On a choisi un AST pour trois raisons :
-
Sécurité. Le BeagleBone est déployé dans des bars. On ne veut pas exécuter du code arbitraire envoyé par le réseau. L’AST ne peut exprimer que des comparaisons et des combinaisons logiques — pas d’effets de bord, pas de boucles infinies, pas d’accès fichier.
-
Sérialisation. La structure Hash/Array/Symbol de Ruby se convertit en JSON sans effort. Le serveur Rails (le “Core”) construit les règles en Ruby, les sérialise en JSON, les envoie via RabbitMQ, et le referee les désérialise nativement.
-
Inspectabilité. On peut loguer la règle en clair, la visualiser, l’afficher dans une interface d’admin. C’est un document, pas du code opaque.
Le trade-off, c’est que certaines règles complexes sont verbeuses. Un simple “le premier à 5 avec 2 d’écart” demande un $and imbriqué. Mais en pratique, les règles les plus complexes qu’on ait vues tiennent en 15 lignes de JSON — largement acceptable.
Le bilan
Le moteur de règles est la partie du code qu’on a le moins touchée après la mise en production. Le format AST a permis au client de modifier les règles sans déploiement sur les tables — un simple changement côté serveur, et la prochaine partie utilise les nouvelles règles.
La simulation potential_winners_with_next_goal est appelée après chaque but. Sur un BeagleBone, elle prend moins d’une milliseconde (4 évaluations d’arbre sur des scores entiers). C’est négligeable comparé aux 70ms du timer de scrolling ou aux 100ms du polling I2C.
Au final, tout le stack — extension C, rendu typographique, moteur de règles — vit dans le même processus Ruby sur un BeagleBone à 1 GHz. Ce n’est pas le hardware le plus puissant du monde, mais avec une architecture simple et des abstractions bien choisies, ça suffit largement pour arbitrer un baby-foot en temps réel.