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 :

  1. Déclaratif — pas de code Ruby dans la config
  2. Composable — des conditions qu’on peut imbriquer
  3. Sérialisable — envoyé en JSON via RabbitMQ depuis le serveur
  4. É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…).

$and$geq$geq$score5$score_delta2évaluation pour hinge, score 6-4$score = 6, $other_score = 4, $score_delta = 2, $max_score = 6→ $geq(6, 5) = true $and $geq(2, 2) = true → 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érateurSémantique
$eqTous les arguments sont égaux
$gtPremier argument strictement supérieur
$geqPremier argument supérieur ou égal
$ltPremier argument strictement inférieur
$leqPremier argument inférieur ou égal

Logiques — combinent des sous-expressions :

OpérateurSémantique
$andToutes les sous-expressions sont vraies
$orAu 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) :

VariableValeur
$scoreScore de l’équipe évaluée
$other_scoreScore de l’adversaire
$score_deltaÉcart absolu entre les deux scores
$max_scoreScore 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_rule est 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.

phases de jeu et transitions:playingdefault rules:tie_breaktie_break rules:finishedwinner != nilstart_rulewin/loosewin directe en defaultgoal_strength — points par actionmode normalbuteuradversairegoal hinge+10goal lock+10inout hinge+1-1inout lock+3-2mode tie-breakbuteuradvers.goal+10inout0-1inouts : 0 pt au buteur

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”.

simulation — score actuel : hinge 4, lock 3score : 4 — 3hinge goal5 — 3→ hinge gagne !hinge inout5 — 3→ hinge gagne !lock goal4 — 4→ pas de vainqueurlock inout4 — 4→ pas de vainqueurrésultatpotential_winners = [:hinge]→ verrouiller IBR côté hinge, libérer côté lock

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 :

  1. Est-ce que win_rule default (5 pts, 2 d’écart) est remplie ? Non, écart = 1.
  2. Est-ce que start_rule tie-break ($max_score >= 5) est remplie ? Oui.
  3. 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 :

  1. 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.

  2. 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.

  3. 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.