Bl!ndt?st est un jeu de blind test multijoueur pour l’événementiel. Un animateur lance la partie sur une tablette, un écran géant affiche les visuels et le score, et les joueurs devinent les morceaux sur leur téléphone — soit en parlant au micro (reconnaissance vocale), soit en QCM. Le tout synchronisé en temps réel via WebSocket.

Le produit existe, il tourne, il est utilisé en soirée d’entreprise. Mais l’équipe a besoin de faire évoluer l’app mobile rapidement : nouvelles mécaniques de jeu (bonus/malus), consolidation du code TypeScript, amélioration de la reconnaissance des noms de chansons, et une question stratégique — est-ce qu’on peut passer sur Android ?

Je suis appelé en renfort pour ces chantiers. Quelques semaines de mission, du rythme, et un produit que je découvre en même temps que je le fais évoluer.

L’architecture : trois écrans, un cerveau

architecture bl!ndt?stAPI FeathersJS + XStateMongoDB · Socket.io · JWTmachine d’état du jeu · scoring · normalisationDisplay (React web)1920×1080 · Lottie · effets sonoresscore · countdown · podiumController iPad (React web)pilotage · playlist · volumegestion micro · équipes · difficultéJoueurs (React Native)reconnaissance vocale · QCMbonus/malus · profil · onboarding← Socket.io temps réel →tous connectés à la même Room · chaque événement passe par la machine XStateCREATED → WAIT_PLAYERS → CHOOSE_MODE → ROUND_PLAYING → ROUND_ENDED → SCOREBOARD

Le produit repose sur trois interfaces distinctes : un écran de diffusion (React web, 1080p) avec les visuels et le score, un contrôleur iPad (React web aussi) pour l’animateur, et l’app mobile React Native sur les téléphones des joueurs. Les trois communiquent en temps réel via Socket.io, orchestrés par une API FeathersJS dont le coeur est une machine d’état XState.

L’app mobile est en React Native 0.64 avec TypeScript, React Navigation 5 et styled-components. C’est elle qui concentre l’essentiel de mon travail.

Ce qui m’a frappé en découvrant la codebase, c’est la clarté de la séparation des responsabilités. L’écran de diffusion ne prend aucune décision de gameplay — il reçoit et affiche. Le contrôleur iPad envoie des intentions (lancer un round, couper un micro), mais c’est le serveur qui valide et exécute. Les téléphones des joueurs transmettent des réponses vocales, mais c’est le serveur qui juge. Cette discipline — un seul lieu de vérité, côté serveur — est ce qui permet de synchroniser 10 appareils sans conflit.

C’est un pattern qu’on retrouve dans le chapitre 11 de Designing Data-Intensive Applications de Martin Kleppmann (2017) : quand plusieurs clients envoient des événements concurrents, la seule façon d’avoir un état cohérent est de sérialiser le traitement dans un point unique. Ici, la machine XState côté serveur joue ce rôle de “single writer”. Chaque GUESS, chaque SET_JUKEBOX, chaque STOP_GUESSING passe par elle, dans l’ordre d’arrivée.

FeathersJS : une découverte agréable

Ce n’est pas un choix que j’aurais fait spontanément. Si je devais monter un backend Node.js aujourd’hui, je partirais probablement sur NestJS, qui a l’avantage d’une structure claire, de l’injection de dépendances, et d’une communauté massive. Mais le backend Bl!ndt?st est en FeathersJS v4 (la v5 est en développement, pas encore sortie), et c’est un choix qui se défend.

FeathersJS est un framework service-oriented. David Luecke, son créateur, décrit le modèle dans son article “Design patterns for modern web APIs” : chaque ressource (games, sessions, users, rooms, library) est un service avec des méthodes standard (find, get, create, patch, remove), et des hooks qui s’exécutent avant et après chaque opération. Le temps réel est natif — pas besoin de câbler Socket.io soi-même, les événements created et patched sont émis automatiquement à chaque mutation.

C’est l’application du pattern Cross-Cutting Concerns appliqué à l’API : la validation, le logging, l’authentification ne polluent pas la logique métier des services, ils vivent dans les hooks. Si on est familier avec les middlewares Express, c’est le même principe, mais à la granularité du service plutôt qu’à celle de la route HTTP.

Côté client React Native, l’intégration tient en quelques lignes :

import feathers from '@feathersjs/feathers';
import auth from '@feathersjs/authentication-client';
import socketio from '@feathersjs/socketio-client';
import io from 'socket.io-client';

const socket = io(API_HOST, {
  path: '/api/socket.io',
  transports: ['websocket'],
  extraHeaders: {
    Authorization: `${API_ID}:${API_PASSWORD}`,
  },
});

const app = feathers();
app.configure(socketio(socket));
app.configure(auth({ storage: AsyncStorage }));

export default app;

Minimaliste. Le client FeathersJS parle WebSocket, gère l’authentification JWT avec persistence dans AsyncStorage, et expose des services qu’on consomme directement dans les hooks React.

Le système de channels : qui reçoit quoi

La vraie complexité de FeathersJS se cache dans le module channels, le mécanisme qui contrôle quels clients reçoivent quelles mises à jour. Dans un jeu multijoueur multi-salles, c’est critique : quand un joueur de la salle A répond, les joueurs de la salle B ne doivent pas recevoir l’événement.

app.on('login', (payload: any, { connection }: any) => {
  if (connection) {
    const { user } = connection;
    if (user.room_id) {
      app.channel(`room-${user.room_id}`).join(connection);
      emitChannelUsers(user.room_id);
    }
  }
});

app.service('games').publish(async (game: Game) => {
  const session = await app.service('sessions').get(game.session_id);
  const lightenedGame = lightenGame(game);
  return app.channel('room-' + session.room_id).send(lightenedGame);
});

Deux détails importants ici. D’abord, le publish sur le service games n’envoie pas l’objet Game brut. Il passe par lightenGame, une fonction qui ne conserve que la phase courante et remplace les phases précédentes par null :

const lightenGame = (game: Game) => {
  const result = cloneDeep(game) as any;
  result.phases = result.phases.map((p: Phase, i: number) =>
    i === result.phases.length - 1 ? p : null,
  );
  return result;
};

C’est une optimisation de bande passante qui prend tout son sens dans le contexte. Une partie peut durer 10 phases de 15 rounds. Chaque round contient la chanson, les réponses de chaque joueur, les scores, les bonus/malus. Sans allègement, l’objet Game enfle à chaque round et chaque mutation envoie tout l’historique à tous les appareils, y compris des téléphones sur un Wi-Fi d’hôtel. En ne gardant que la phase en cours dans le payload WebSocket, on divise la taille des messages par un facteur qui croît avec la durée de la partie.

Ensuite, le système de channels gère la mobilité des connexions entre les salles. Quand un utilisateur change de room (reconnexion, changement de session), le hook onUserPatch quitte les channels précédents et rejoint le nouveau :

const onUserPatch = (user: User) => {
  // Quitter les anciens channels
  const previousChannels = app.channels
    .filter(c => c !== 'default')
    .filter(c => /* user is in this channel */)
    .map(c => { app.channel(c).leave(/* user */); return c; });

  // Rejoindre le nouveau channel
  if (user.room_id) {
    connections.forEach(co => {
      app.channel(`room-${user.room_id}`).join(co);
    });
  }

  // Notifier les deux rooms (ancienne et nouvelle) de la liste de joueurs
  channelsToEmit.forEach(emitChannelUsers);
};

C’est le genre de plomberie qu’on ne voit pas dans les tutoriels FeathersJS. La documentation montre un channel unique, une salle, tout le monde ensemble. En production multi-salles avec des appareils qui se reconnectent, il faut penser au cycle de vie complet de la connexion.

L’écoute temps réel côté client

Pour écouter les mises à jour en temps réel, on a un hook useListener générique :

export const useListener = <T extends {_id?: string; updated_at: Date}>({
  element, setter, serviceName,
}: {
  element?: T;
  setter: (e: any) => void;
  serviceName: string;
}) => {
  useEffect(() => {
    if (!element?._id) return;

    const handler = (force = false) => (message: T) => {
      // On ignore les updates plus anciennes que l'état courant
      setter((element: T) =>
        !element ||
        (dayjs(message.updated_at).isSameOrAfter(element.updated_at) &&
          (force || message._id === element._id))
          ? message
          : element,
      );
    };

    const patchHandler = handler();
    const createHandler = handler(true);

    feathers.service(serviceName).on('created', createHandler);
    feathers.service(serviceName).on('patched', patchHandler);

    return () => {
      feathers.service(serviceName).removeListener('created', createHandler);
      feathers.service(serviceName).removeListener('patched', patchHandler);
    };
  }, [element?._id]);
};

La comparaison via dayjs().isSameOrAfter() est le genre de détail qu’on ne met pas au premier jet. Elle est là parce que les événements Socket.io arrivent parfois dans le désordre — un patched déclenché par le contrôleur iPad peut arriver après un patched déclenché par un joueur, et sans garde temporelle, l’état du jeu régresse. C’est un problème classique de systèmes distribués : l’ordre d’émission n’est pas l’ordre de réception. Le timestamp updated_at sert d’horloge logique pour garantir qu’on ne recule jamais.

Ce hook est utilisé partout : pour l’état de la partie, la session, les utilisateurs connectés.

Context API + custom hooks : le pattern de gestion d’état

Le state management de l’app mobile mérite qu’on s’y arrête. Pas de Redux, pas de MobX. Juste React Context et des custom hooks. En 2021, c’est un choix qui divise. Kent C. Dodds a publié “Application State Management with React” en 2019, où il défend l’idée que React lui-même est une solution de state management suffisante pour la majorité des apps. Dan Abramov (co-créateur de Redux, ironie du sort) a écrit “Before You memo()” sur overreacted.io, qui pousse dans la même direction : coloquer l’état au plus près de là où il est consommé.

Redux Toolkit existe, Zustand commence à monter (sorti en 2019 par Jared Palmer, maintenu par Daishi Kato), Recoil de Facebook est en experimental depuis la React Europe 2020. Mais pour cette app, le pattern Context + hooks est le bon choix. Voici pourquoi.

L’AppProvider agrège quatre hooks spécialisés :

export const AppProvider: FC = ({children}) => {
  const {session, setSession} = useSession();
  const {game, createGame, patchGame} = useGame({session});
  const {users} = useUsers();
  const {library} = useLibrary({game});

  useInit({ setSession });

  return (
    <AppContext.Provider
      value={{session, users, game, createGame, patchGame, library}}>
      {children}
    </AppContext.Provider>
  );
};

Chaque hook encapsule une responsabilité : useSession gère la session courante et sa persistence dans AsyncStorage, useGame gère la partie en cours avec son listener temps réel, useUsers écoute les connexions/déconnexions, useLibrary charge les playlists. useInit orchestre la reconnexion au démarrage (retrouver la dernière session, se ré-authentifier auprès de FeathersJS).

Le useGame illustre bien le pattern :

export const useGame = ({session}: {session?: Session}) => {
  const [game, setGame] = useState<Game>();

  const createGame = async (data: any = {}) => {
    const r = await feathers.service('games').create(data);
    setGame(r);
  };

  useEffect(() => {
    if (session) {
      feathers.service('games')
        .get(last(session.games_ids))
        .then((g: Game) => setGame(g));
    }
  }, [session]);

  useListener<Game>({
    element: game,
    serviceName: 'games',
    setter: setGame,
  });

  const patchGame = async (e: GameEvent) => {
    if (!game) return;
    return await feathers.service('games').patch(game._id, e);
  };

  return {game, createGame, patchGame};
};

Trois sources mettent à jour l’état : la création initiale, le fetch quand la session change, et le listener temps réel. Les trois convergent vers le même setGame. C’est simple, mais c’est exactement ce qu’il faut. L’alternative Redux aurait ajouté des actions, des reducers, un middleware pour les side effects (thunks ou sagas), un sélecteur pour extraire le game du store global — tout ça pour un état qui est déjà naturellement scoped à l’arbre React.

La question qu’un architecte se pose : est-ce que ça scale ? Pour cette app, oui. L’état global tient en quatre objets (session, game, users, library). Les re-renders provoqués par le Context quand le game change touchent tout l’arbre, mais l’arbre en question a 6 écrans dont un seul est actif à la fois. On n’est pas dans le cas pathologique d’un store Redux qui change 60 fois par seconde et re-render 200 composants. Le game change quand quelqu’un répond, quand un round se termine, quand un malus est assigné — peut-être 2-3 fois par seconde au pic. C’est dans les limites de confort du Context API.

XState : la machine d’état qui pilote tout

Le coeur du jeu est une machine d’état XState côté serveur. Chaque partie est un automate fini qui passe par une douzaine d’états : de CREATED à GAME_ENDED, en passant par WAIT_PLAYERS, CHOOSE_MODE, ROUND_PLAYING, ROUND_ENDED, SCOREBOARD. Chaque transition est gardée par des conditions, et chaque entrée dans un état déclenche des actions.

machine d’état xstate — cycle de vie d’une partieCREATEDWAIT_PLAYERSCHOOSE_MODECHOOSE_PLAYLISTSTART_PHASEentry: generateRoundsROUND_STARTINGentry: prepareRoundROUND_PLAYINGGUESS (cond: isGuessCorrect)ROUND_ENDEDentry: onRoundEnded (scoring)!isLastRoundisLastRoundSCOREBOARD→ NEW_PHASE ou GAME_ENDED

L’utilisation d’un statechart pour modéliser un jeu n’est pas une idée nouvelle. David Harel a formalisé les statecharts en 1987 dans “Statecharts: A Visual Formalism for Complex Systems”, et l’idée centrale — que les automates à états finis, enrichis de hiérarchie et de concurrence, peuvent modéliser des systèmes complexes de façon lisible — s’applique parfaitement à un jeu de quiz multijoueur. David Khourshid, le créateur d’XState, a donné plusieurs talks en 2021 (React Summit, React Finland) pour montrer comment les statecharts éliminent les “impossible states” dans les applications interactives.

La machine de Bl!ndt?st :

const machine = (game: Game): StateMachine<Game, any, HookGameEvent> =>
  createMachine<Game, HookGameEvent>({
    id: 'game_machine',
    initial: GameState.CREATED,
    context: game,
    states: {
      [GameState.ROUND_PLAYING]: {
        entry: ['handleRoundPlaying'],
        on: {
          [GameTransition.GUESS]: [{
            target: GameState.ROUND_ENDED,
            cond: (game, event) => isGuessCorrect(game, event),
          }],
          [GameTransition.STOP_GUESSING]: GameState.ROUND_ENDED,
          [GameTransition.SET_JUKEBOX]: { actions: ['doAssign'] },
        },
      },
      [GameState.ROUND_ENDED]: {
        entry: ['onRoundEnded'],
        on: {
          [GameTransition.VALIDATE_ROUND_ENDED]: [
            { target: GameState.SCOREBOARD, cond: (game) => game.finishing },
            { target: GameState.SCOREBOARD, cond: (game) => isLastRound(game) },
            { target: GameState.ROUND_STARTING, cond: (game) => !isLastRound(game) },
          ],
        },
      },
      // ...12 états au total
    },
  });

XState v4 est parfait pour ça. La machine est déclarative, les transitions sont explicites, et on ne peut pas arriver dans un état incohérent. Quand un joueur soumet une réponse (GUESS), la garde isGuessCorrect vérifie la réponse : si elle est correcte, la partie passe en ROUND_ENDED. Sinon, rien ne se passe — la machine ignore l’événement. Pas de if/else imbriqués, pas d’états impossibles à reproduire en debug.

La sérialisation comme protection contre les race conditions

C’est aussi une protection contre les race conditions. Quand 8 joueurs crient le nom de l’artiste en même temps et que 8 WebSocket envoient GUESS dans la même seconde, la machine traite les événements séquentiellement. Le premier GUESS correct déclenche la transition vers ROUND_ENDED. Les suivants arrivent dans l’état ROUND_ENDED, qui n’a pas de handler pour GUESS — ils sont simplement ignorés.

Sans machine d’état, il faudrait gérer ça à la main : un flag hasBeenGuessed, un mutex, ou un if qui check l’état avant chaque traitement. C’est faisable, mais c’est exactement le genre de code qui accumule les bugs subtils au fil des évolutions. Avec la machine, la protection est structurelle. Un événement qui n’a pas de transition dans l’état courant est ignoré par construction.

Le scoring : entry actions et courbe de difficulté

L’action onRoundEnded qui se déclenche à l’entrée de ROUND_ENDED calcule le score en tenant compte de la vitesse, du type de chanson et des bonus :

Les points de base sont 10 pour une bonne réponse, +10 si c’est un duo (le joueur doit deviner les deux artistes). La rapidité ajoute un tier : +10 sous 3 secondes, +5 sous 5 secondes, +2 sous 10 secondes. Les multiplicateurs bonus (×2, ×4) s’appliquent sur le total du round.

La sélection des chansons elle-même suit une logique sophistiquée. L’algorithme getSongsFromPlaylist évite de répéter les artistes, priorise les chansons les moins jouées dans la session, et gère une courbe de difficulté : le premier round est de difficulté moyenne (pour engager les joueurs), les rounds suivants montent, le dernier est facile (pour finir sur une note positive). C’est du game design encodé dans un algorithme :

// Premier round : difficulté 2 (accessible)
// Rounds intermédiaires : difficulté 5 (max)
// Dernier round : difficulté 1 (tout le monde peut répondre)
difficulty: i === 0 ? 2 : i === phaseLength - 1 ? 1 : 5,

Pour les playlists qui gèrent finement les niveaux, une matrice de distribution répartit les chansons par difficulté :

const songsPerLevel = [
  [15],           // difficulté 1 : 15 chansons faciles
  [7, 8],         // difficulté 2 : 7 faciles + 8 moyennes
  [3, 4, 8],      // difficulté 3 : 3 faciles + 4 moyennes + 8 dures
  [1, 2, 4, 8],   // difficulté 4 : pyramide inverse
  [0, 1, 2, 4, 8] // difficulté 5 : presque que du très dur
];

Puis les chansons sont mélangées et réordonnées : la plus difficile en premier, la plus facile en dernier. C’est la même logique que les quiz télé — commencer fort pour capter l’attention, finir facile pour que tout le monde finisse content.

Améliorer la reconnaissance des noms de chansons

La reconnaissance vocale est le coeur de l’expérience joueur. Le téléphone écoute, l’API Speech-to-Text d’iOS (ou Google Speech sur Android) transcrit, et le serveur compare la transcription au nom de l’artiste attendu. Le problème : les moteurs de reconnaissance vocale sont optimisés pour le langage courant, pas pour les noms d’artistes. “Daft Punk” peut sortir comme “draft punk”, “daft pong”, ou “des poncs”.

Le bridge natif : le coeur du travail client

L’app utilise un bridge natif custom pour la reconnaissance vocale — pas la librairie communautaire @react-native-voice/voice, mais un module maison (VoiceModule) qui permet un contrôle fin sur le moteur de chaque plateforme. C’est ce module qui fait la différence entre une expérience frustrante et une expérience fluide.

Sur iOS, le bridge encapsule SFSpeechRecognizer d’Apple. La feature critique : la propriété contextualStrings (disponible depuis iOS 13) permet de passer au moteur un tableau de mots attendus. Avant chaque round, on injecte la liste des artistes de la playlist courante. Le moteur est biaisé vers ces noms : “Daft Punk” a beaucoup plus de chances de sortir correctement que sans indice. On peut aussi passer des variantes connues (prononciations approximatives fréquentes, orthographes alternatives) pour que le moteur les reconnaisse directement.

Mon travail sur le bridge iOS a consisté à améliorer cette intégration : passer non seulement les noms d’artistes mais aussi les erreurs fréquentes constatées en test (les “mauvais mots” que le moteur renvoie souvent pour un artiste donné), pour que le moteur apprenne à distinguer ces cas. C’est un usage un peu détourné de contextualStrings — Apple le conçoit pour ajouter du vocabulaire, on l’utilise aussi pour signaler des confusions — mais ça fonctionne.

Sur Android, la situation est radicalement différente. L’API SpeechRecognizer ne propose rien d’équivalent aux contextualStrings. Pas de vocabulary hints, pas de moyen de biaiser la transcription. Le moteur renvoie ce qu’il pense avoir entendu, point. Le bridge Android doit donc compenser côté client :

if (Platform.OS === 'android') {
  Voice.startSpeech(locale, dictionary, Object.assign({
    EXTRA_LANGUAGE_MODEL: 'LANGUAGE_MODEL_FREE_FORM',
    EXTRA_MAX_RESULTS: 5,
    EXTRA_PARTIAL_RESULTS: true,
    REQUEST_PERMISSIONS_AUTO: true,
  }, options), callback);
} else {
  Voice.startSpeech(locale, dictionary, callback);
}

Le EXTRA_MAX_RESULTS: 5 est clé. Android renvoie jusqu’à 5 alternatives de transcription classées par confiance décroissante. La bonne réponse est souvent en 2e ou 3e position. Pour “Daft Punk”, on peut recevoir "draft punk" en premier et "daft punk" en second. En matchant toutes les alternatives au lieu de la seule première, on récupère un bon paquet de points de reconnaissance.

C’est cette asymétrie iOS/Android qui m’a conduit à explorer les algorithmes de fuzzy string matching pour le POC Android — Jaro-Winkler pour la similarité entre noms courts, coefficient de Dice pour la tolérance aux mots réordonnés, Levenshtein pour la distance d’édition. J’en parle en détail dans un article séparé sur les différences de reconnaissance vocale iOS/Android.

Le matching côté serveur

Côté serveur, le matching existant repose sur une normalisation agressive (slugify + uppercase + suppression des mots de liaison comme THE, AND, ET) suivie d’un simple indexOf. C’est volontairement permissif : un joueur qui dit “c’est les Rolling Stones non ?” doit être reconnu. La base de données stocke des variantes connues de chaque artiste dans un champ artist_siri_col, ce qui élargit la tolérance sans complexifier l’algorithme. Mon travail côté serveur s’est limité à élargir les mots filtrés et à mieux gérer les caractères spéciaux des noms d’artistes internationaux — l’essentiel de l’effort était sur le client.

Types, navigation et consolidation : investir dans la maintenabilité

L’app existante fonctionnait, mais le TypeScript était sous-exploité. Beaucoup de any, des props de navigation non typées, des types partagés entre le front web et le back qui ne couvraient pas tous les cas côté mobile. Le strict mode de TypeScript n’était pas activé — c’est le cas de la majorité des projets React Native en 2021, où l’écosystème de types des librairies tierces est encore inégal.

La navigation : typage total et modales propres

Le premier chantier, et le plus visible, a été de reprendre la navigation de zéro. L’existant enchaînait des stack navigators imbriqués sans logique claire — des écrans modaux montés comme des écrans normaux, des retours arrière imprévisibles, des transitions incohérentes. Le genre de dette qui s’accumule quand on ajoute des écrans un par un sans plan d’ensemble.

La refonte sépare clairement les écrans “pleins” (le flux principal : splash → onboarding → profil → jeu) des modales (CGU, erreur réseau, paramètres). En React Navigation v5, la façon propre de faire des modales est d’utiliser un stack navigator racine en mode modal, avec les écrans modaux déclarés au même niveau que le flux principal — pas dans un navigator enfant. Ça donne des animations de présentation correctes (slide from bottom au lieu de push from right), et surtout un comportement de dismiss cohérent.

Le typage complet des routes ferme la boucle :

export type RootStackParamList = {
  SplashScreen: undefined;
  ProfileScreen: undefined;
  AuthScreen: undefined;
  OnboardingScreen: undefined;
  GameScreen: undefined;
  ErrorScreen: undefined;
};

Avec ce type, useNavigation<StackNavigationProp<RootStackParamList>>() refuse de naviguer vers un écran qui n’existe pas. Les navigation.navigate('GmaeScreen') avec une typo sont attrapés à la compilation au lieu de crasher en runtime sur le téléphone d’un joueur pendant une soirée. Chaque écran déclare ses params attendus (ou undefined s’il n’en a pas), et le compilateur vérifie qu’on les passe correctement à chaque navigate().

Corriger les bugs de synchronisation WebSocket → état local

Le deuxième chantier a été moins visible mais tout aussi important : traquer et corriger les bugs de synchronisation entre les événements WebSocket et l’état local React. Le symptôme classique : l’écran affiche un round terminé alors que le serveur est déjà au round suivant. Ou inversement, un joueur voit le countdown du prochain round alors que le round précédent n’est pas encore affiché comme terminé.

La cause racine est toujours la même : les événements Socket.io arrivent dans le désordre, et le code qui les consomme ne vérifie pas la fraîcheur. Le useListener avec sa garde dayjs().isSameOrAfter() était déjà en place, mais certains chemins de mise à jour contournaient le listener — des setGame directs après un patchGame, des useEffect qui se déclenchaient sur le mauvais état. Le résultat : des race conditions subtiles où l’état local oscillait entre deux versions du game object.

La correction a consisté à systématiser le flux : chaque mutation passe par feathers.service('games').patch(), et l’état local ne se met à jour qu’en réponse à l’événement patched reçu via le listener. Le patchGame ne fait plus de setGame optimiste — il envoie l’événement au serveur et attend que le listener confirme. C’est un peu plus lent en apparence (un aller-retour WebSocket), mais dans un jeu multi-appareils, un état cohérent vaut plus qu’un état rapide. C’est le principe de single source of truth poussé jusqu’au bout : le serveur est la vérité, le client est un miroir.

Types partagés : un contrat entre trois codebases

Le troisième chantier, plus structurant, a été la consolidation des types partagés. Le projet web utilise un symlink (front/src/commonapi/src/common) pour partager les types entre le front React et l’API. L’app mobile, elle, avait ses propres types dans src/types/types.ts, parfois en décalage avec le serveur.

Les types côté serveur définissent 12 états de jeu, 29 transitions, les structures de Round, Song, Player, les enums Bonus/Malus. Côté mobile, ces mêmes types existaient mais avec des valeurs parfois différentes, des champs manquants. Un GameState.CHOOSE_DIFFICULTY côté serveur n’avait pas de correspondance côté mobile. Quand le jeu entrait dans cet état, l’app ne savait pas quoi afficher.

Le travail de consolidation a consisté à aligner les types mobiles sur les types serveur : mêmes enums, mêmes structures, mêmes noms de champs. Le symlink n’est pas applicable à React Native (le bundler Metro ne suit pas les symlinks par défaut), mais la source de vérité est clairement le fichier api/src/common/types.ts. Le fichier mobile en est une copie fidèle, avec les extensions spécifiques au rendu.

C’est un investissement dans la pérennité. Quand le prochain développeur ajoute un état à la machine XState, il sait qu’il doit mettre à jour les types aux trois endroits. Et TypeScript le rappellera s’il oublie — les switch non exhaustifs, les propriétés manquantes dans les interfaces, les transitions non gérées remontent comme des erreurs de compilation, pas comme des bugs en production.

Le système de bonus/malus

La feature principale de cette itération. Chaque round peut attribuer un bonus ou un malus à une équipe. Les malus vont du simple NOT_FIRST (pénalité si tu ne réponds pas en premier) au MICRO_15 (micro coupé pendant 15 secondes — tu ne peux pas buzzer). Les bonus sont des multiplicateurs de score : ×2 en reconnaissance vocale, ×4 en QCM.

export const getBonusMalus = (game: Game, user: User): IBonusMalus => {
  const phase = game.phases[game.current_phase];
  const round = phase.rounds[phase.current_round];
  const teams = game.teams;

  const teamIndex = teams.findIndex(t => t.players.includes(user._id));

  const malus = round.malus[teamIndex];
  let timer = null;

  switch (malus) {
    case Malus.MICRO_10: timer = 10; break;
    case Malus.MICRO_15: timer = 15; break;
    default: timer = null; break;
  }

  return {bonus: round.bonus[teamIndex], malus: {type: malus, timer}};
};
système bonus / malus — impact sur le gameplayMalusMICRO_10micro coupé 10s → countdown visibleMICRO_15micro coupé 15s → handicap sévèreNOT_FIRSTpénalité si pas premier à répondreMALUS_IF_WRONGpoints retirés si mauvaise réponseONLY_ONEun seul joueur de l’équipe peut jouerBonusBONUS_STEP_1reconnaissance vocale · score ×2BONUS_STEP_2bascule en QCM · score ×2BONUS_STEP_3bascule en QCM · score ×4affiché via BonusCard · vibration à l’activation

Ce qui est intéressant architecturalement, c’est que le bonus/malus est assigné côté serveur dans les actions de la machine XState et stocké dans l’objet Round. Le front mobile ne calcule rien, il lit l’état et adapte l’UI. getBonusMalus est un pur sélecteur : il extrait du contexte de jeu le bonus et le malus de l’équipe du joueur courant. Cette discipline — le serveur décide, le client affiche — est ce qui rend la feature triviale à ajouter malgré la complexité de la synchronisation multi-appareils.

Côté mobile, quand un malus MICRO_10 ou MICRO_15 est assigné, le bouton d’enregistrement passe en opacité réduite et un countdown s’affiche. Le joueur voit le temps défiler sans pouvoir parler. C’est cruel, c’est le but. Quand un bonus arrive, une carte “CADEAU BONUS” s’affiche avec le multiplicateur, accompagnée d’un retour haptique (vibration). L’assignation se fait côté serveur à chaque round — le front ne décide jamais.

Lottie : des animations riches sans se battre avec OpenGL

L’écran de diffusion (la partie web du projet) utilise des animations Lottie pour les moments forts : le countdown avant chaque round, les feux d’artifice du podium, l’aiguille de vinyle qui tombe sur le disque au lancement, les ondes sonores quand un joueur parle dans son micro. Sept fichiers JSON d’animation, pilotés par react-lottie-player.

<Lottie
  animationData={countdownNumbersData}
  play
  style={{ width: size, position: 'absolute', transform: 'translateX(-50%)' }}
  segments={[510 - (delay * 60 + 15), 510]}
/>
<Lottie
  animationData={countdownCircleData}
  play
  style={{ width: size, position: 'absolute', transform: 'translateX(-50%)' }}
  speed={1 / delay}
/>

Le countdown combine deux animations Lottie superposées : les chiffres (avec des segments calculés pour démarrer au bon numéro selon le délai) et le cercle (dont la speed est ajustée pour que le tour complet dure exactement le temps du countdown). À ça s’ajoutent des effets sonores synchronisés via use-sound et howler. Le résultat est un countdown qui combine animation fluide, son, et une précision temporelle pilotée par le code — pas par la timeline de l’animation.

Ce qui m’a frappé en travaillant sur ce projet, c’est le ratio effort/résultat de Lottie. Un designer exporte une animation After Effects en JSON via le plugin Bodymovin, on la drop dans le composant React, et on a un visuel animé fluide, résolution-indépendant, qui pèse quelques Ko. Le podium avec feux d’artifice, le vinyle qui tourne, le countdown chiffré — tout ça aurait pris des jours à coder à la main en canvas ou en SVG animé.

Par contraste, je repense au projet de tracking vidéo pour le baby-foot Tekbak où on avait construit un système de particules de feu en C++ avec Cinder et OpenGL. Du curl noise basé sur le papier de Bridson (SIGGRAPH 2007), des shaders, du GL_POINTS avec additive blending, une rampe de couleur calculée par particule — beau, mais des semaines de travail pour un effet visuel. Le développeur devait comprendre les maths de la simulation fluide pour modifier l’apparence des flammes.

Avec Lottie, le même genre de “wow moment” (les feux d’artifice du podium, par exemple) prend une après-midi : le designer anime, le dev intègre. La frontière entre les métiers est nette. Le développeur n’a pas besoin de comprendre les maths derrière l’animation, et le designer n’a pas besoin de toucher au code. C’est le genre de séparation des préoccupations qui rend un projet plus facile à faire évoluer dans la durée.

Ce n’est pas une critique du C++ ou d’OpenGL — il y a des cas où le contrôle pixel-perfect et la performance native sont indispensables (du tracking vidéo à 120fps en est un). Mais pour des animations d’interface dans un jeu web, Lottie est un outil extraordinairement efficace. La maturité de la librairie en 2021 (Airbnb l’a open-sourcée en 2017, elle est utilisée par Uber, Google, Spotify) en fait un choix pérenne.

L’étude Android : est-ce que ça passe ?

Bl!ndt?st tourne sur iOS. La question stratégique : peut-on sortir une version Android sans réécriture ? En théorie, React Native est cross-platform. En pratique, l’app a un module natif custom pour la reconnaissance vocale (VoiceModule.m / VoiceModule.h en Objective-C), un contrôleur audio natif en Swift (AudioController.swift qui utilise AVFoundation), et des dépendances CocoaPods qui n’ont pas toutes un équivalent Android.

La réponse courte : oui, c’est faisable. La réponse longue demande de distinguer trois couches.

Le VoiceModule est le point le plus critique. Sur iOS, il utilise SFSpeechRecognizer d’Apple. Sur Android, il faut passer par l’API Google Speech Recognition, qui a une interface différente. Le wrapper React Native (Voice.tsx) anticipe déjà la distinction avec un Platform.OS === 'android' et des options spécifiques (EXTRA_LANGUAGE_MODEL, EXTRA_MAX_RESULTS). Le bridge natif Android reste à écrire, mais l’architecture est prête — c’est un bon signe. Quand le code JavaScript anticipe les deux plateformes avant même que la plateforme existe, ça veut dire que le développeur original a pensé cross-platform dès le début.

L’audio est plus simple. React Native 0.64 avec Hermes sur Android est stable. Hermes est disponible sur Android depuis React Native 0.60.4 (mi-2019) et a deux ans de maturité sur cette plateforme — contrairement à iOS où il vient d’arriver avec la 0.64 en mars. La lecture audio peut passer par une solution cross-platform (react-native-sound ou équivalent) plutôt que par le contrôleur AVFoundation natif.

Les dépendances tiers posent peu de problèmes. styled-components/native, react-navigation, @react-native-community/async-storage, react-native-svg, react-native-device-info — tout ça supporte Android. react-native-permissions gère les deux plateformes. Les seuls ajustements sont les permissions Android (RECORD_AUDIO en runtime depuis Android 6) et la configuration Gradle.

Le POC a confirmé la faisabilité : l’app se lance sur Android, la navigation fonctionne, l’affichage est correct. Le bridge vocal natif Android est le chantier principal pour une mise en production, le reste est du polish (dimensions d’écran, comportement du clavier, gestion du back button Android).

Les décisions qui comptent

En mission de renfort, la tentation est de tout refaire. Chaque projet a des choix qu’on aurait faits différemment. Mais un dev senior qui débarque pour quelques semaines doit distinguer ce qui apporte de la valeur immédiate de ce qui déstabilise pour un gain théorique. Quelques décisions qui m’ont guidé.

Ne pas migrer React Navigation de v5 à v6. La v6 est sortie en août. Elle apporte du polish (meilleur typage des refs, API de groupe simplifiée), mais l’effort de migration touche chaque écran. Sur une mission courte, c’est du risque pour peu de gain. La v5 est stable, elle ne sera pas dépréciée avant longtemps, et le modèle de typage est identique dans ses grandes lignes.

Ne pas introduire un state manager externe. Le Context + hooks est simple, lisible, suffisant pour la taille de cette app. Zustand ou Jotai auraient pu être de bons choix techniques, mais ils auraient introduit un pattern que l’équipe ne connaît pas, dans une app qui fonctionne déjà. Kent C. Dodds écrit dans “Application State Management with React” que pour la majorité des apps, React lui-même est suffisant. Cette app en est une illustration.

Consolider les types plutôt qu’activer strict mode. Le strict mode TypeScript sur un projet React Native existant en 2021, c’est ouvrir la boîte de Pandore. Beaucoup de librairies tierces (react-native-device-info, react-native-permissions) ont des types incomplets. Activer strict: true aurait surfacé des centaines d’erreurs dans des dépendances qu’on ne contrôle pas. Le compromis : typer les interfaces critiques (navigation, game state, FeathersJS services) et laisser les bords du système en any assumé.

Écrire le POC Android sans toucher à l’architecture. L’étude de faisabilité confirme que le code JavaScript est portable, identifie le bridge vocal comme seul chantier natif, et donne une estimation concrète. C’est plus utile au client qu’un prototype fonctionnel à 80% qui serait tenté d’être poussé en prod.

Ce qu’on a livré

La consolidation TypeScript rend le projet maintenable par quelqu’un qui ne connaît pas le produit. Les types de navigation, les interfaces partagées entre front et back, la suppression des any aux endroits critiques — c’est le genre de travail invisible qui divise par deux le temps d’onboarding du prochain développeur.

Le système bonus/malus ajoute une couche stratégique au jeu. Les soirées d’entreprise ne sont plus juste “qui connaît le plus de chansons” mais “qui gère le mieux les handicaps et les opportunités”. C’est aussi une preuve que l’architecture XState se prête bien aux évolutions : ajouter un nouveau type de malus, c’est un enum, une action, et un composant d’affichage. La machine d’état ne demande pas de refactoring, elle absorbe les nouvelles règles.

L’amélioration de la reconnaissance a un impact direct sur l’expérience. Mieux normaliser les noms d’artistes, c’est moins de “mais j’avais dit le bon nom !” pendant les parties. C’est subtil, mais dans un jeu social, la frustration d’une mauvaise reconnaissance peut casser l’ambiance.

L’étude Android donne au client une feuille de route claire : le POC démontre la faisabilité, identifie le bridge vocal comme chantier principal, et confirme que l’investissement React Native tient sa promesse de cross-platform — avec du travail natif ciblé, pas une réécriture.

Et au-delà des livrables, ce projet a confirmé quelque chose que je retrouve mission après mission : les meilleures architectures ne sont pas les plus sophistiquées, mais celles où chaque choix a une raison. FeathersJS plutôt que NestJS parce que le temps réel est natif. XState plutôt que des flags booléens parce que le jeu a 12 états et 29 transitions. Context API plutôt que Redux parce que l’état tient en quatre objets. Des décisions simples, prises une par une, qui s’additionnent en un système cohérent.

Nos autres retours d’expérience en React Native : la réécriture de ChocoBonPlan avec Rematch, et l’upload vidéo avec compression.