# Bl!ndt?st — Consolider une app React Native de blind test multijoueur en temps réel > Retour d'expérience sur une mission de renfort dev pour Bl!ndt?st, un jeu de blind test multijoueur en temps réel. Restructuration d'une app React Native, ajout de types, amélioration de la reconnaissance vocale, découverte de FeathersJS et de Lottie, et étude de faisabilité Android. Date : 15/11/2021 Auteur : Aurélien N. Tags : React Native, TypeScript, FeathersJS, XState, Socket.io --- [Bl!ndt?st](https://www.thisisblindtest.com/) 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 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 : ```typescript const socket = io(API_HOST, { path: '/api/socket.io', transports: ['websocket'], extraHeaders: { Authorization: `$:$`, }, }); const app = feathers(); app.configure(socketio(socket)); app.configure(auth()); ``` 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. ```typescript app.on('login', (payload: any, : any) => { if (connection) { const = connection; if (user.room_id) { app.channel(`room-$`).join(connection); emitChannelUsers(user.room_id); } } }); app.service('games').publish(async (game: Game) => ); ``` 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` : ```typescript const lightenGame = (game: Game) => ; ``` 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 : ```typescript 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 => ); // Rejoindre le nouveau channel if (user.room_id) { connections.forEach(co => { app.channel(`room-$`).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 : ```typescript element, setter, serviceName, }: ) => { useEffect(() => { if (!element?._id) return; const handler = (force = false) => (message: T) => ; const patchHandler = handler(); const createHandler = handler(true); feathers.service(serviceName).on('created', createHandler); feathers.service(serviceName).on('patched', patchHandler); return () => ; }, [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 : ```typescript const = useSession(); const = useGame(); const = useUsers(); const = useLibrary(); useInit(); return ( ); }; ``` 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 : ```typescript const [game, setGame] = useState(); const createGame = async (data: any = ) => ; useEffect(() => { if (session) }, [session]); useListener(); const patchGame = async (e: GameEvent) => ; return ; }; ``` 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. 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 : ```typescript const machine = (game: Game): StateMachine => createMachine({ id: 'game_machine', initial: GameState.CREATED, context: game, states: { [GameState.ROUND_PLAYING]: { entry: ['handleRoundPlaying'], on: { [GameTransition.GUESS]: [], [GameTransition.STOP_GUESSING]: GameState.ROUND_ENDED, [GameTransition.SET_JUKEBOX]: , }, }, [GameState.ROUND_ENDED]: { entry: ['onRoundEnded'], on: { [GameTransition.VALIDATE_ROUND_ENDED]: [ , , , ], }, }, // ...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 : ```typescript // 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é : ```typescript 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 : ```typescript if (Platform.OS === 'android') { Voice.startSpeech(locale, dictionary, Object.assign(, options), callback); } else ``` 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](/blog/reconnaissance-vocale-ios-android-react-native-string-matching). ### 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 : ```typescript SplashScreen: undefined; ProfileScreen: undefined; AuthScreen: undefined; OnboardingScreen: undefined; GameScreen: undefined; ErrorScreen: undefined; }; ``` Avec ce type, `useNavigation>()` 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/common` → `api/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. ```typescript 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) return {bonus: round.bonus[teamIndex], malus: }; }; ``` 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`. ```tsx ``` 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](/blog/fsb-tracking-vision-cinder-opencv-cpp) 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](/blog/chocobonplan-rewrite-react-native-architecture-rematch), et l'[upload vidéo avec compression](/blog/upload-video-react-native-compression-formdata)._