Sommaire
Cher journal,
Ça commence à faire un moment que je ne t'ai pas parlé de Bim! et j'étais effectivement sur un gros morceau ces derniers mois. Mais maintenant c'est bon, je pense avoir stabilisé mon truc et j'ai publié la version 17 récemment. Cool !
Pour rappel, Bim! est un jeu libre (code AGPL3 et assets CC-by-sa 4.0) multijoueur de type dernier survivant, et qui se joue uniquement en ligne. Il n’est disponible que pour les systèmes Android.
En plus du PlayStore, le jeu est disponible sur GitHub, F-Droid, et d’autres magasins alternatifs.
Le jeu ressemble à ceci :

Cette nouvelle version est principalement technique. Du côté des nouveautés visibles pour l'utilisateur on aura :
- Le joueur a maintenant un pseudo. Celui-ci s'affiche sur l'écran de recherche d'adversaires.
- La durée de l'invincibilité temporaire lors de la perte du bouclier est réduite.
- Les joueurs qui perdent avant la fin d'un match nul sont considérés comme perdants (auparavant ils étaient inclus dans les ex æquo).
C'est pas énorme, alors pourquoi cela a-t-il été si long ?
Introduction de serveur business
Il y a dans la liste ci-dessous un petit truc qui a fini par me convaincre de développer un serveur « business » pour gérer l'inventaire des joueurs, la config cliente, les serveurs de jeu disponibles, etc. Il s'agit du pseudo du joueur.
Historiquement dans Bim! il n'y avait que deux gros morceaux : le client et le serveur de jeu ; et accessoirement un fichier JSON sur un serveur web, que le client allait chercher pour récupérer quelques paramètres de configuration de l'application et en particulier l'adresse du serveur de jeu. Tout l'état du joueur était sur le client (nombre de pièces, fonctionnalités acquises ou sélectionnées, etc.) Le serveur de jeu s'occupait de faire rencontrer les joueurs et leur disait en fin de partie combien de pièces ils avaient gagné.
Cela me gênait un peu sur plusieurs aspects. Il y a évidemment un risque de triche à laisser le client gérer ses données mais bon, avec ma petite base d'utilisateurs je pense que ça n'a pas d'importance. Par contre, le fait d'avoir à maintenir à la main le fichier de config sur le serveur web, ça me cassait les pieds. J'ai toujours peur de me planter dans la syntaxe et je me demandais fréquemment si j'avais bien uploadé le bon fichier. Enfin, conceptuellement, ça me semblait sentir la vieille chaussette sale.
À côté de cela je voyais bien qu'il y avait des fonctionnalités envisagées qui seraient bien difficiles à faire sans avoir un serveur business. Par exemple j'aimerais faire des salles privées pour que les joueurs puissent décider de jouer ensemble plutôt que de subir le matchmaking global. Difficile de faire ça sans avoir une persistance sur un serveur. Si en plus on imagine que ces salles sont payantes et partageables, ça complexifie encore. De même si je veux faire un leaderboard, je ne vais pas le stocker sur le client.
Le pseudo du joueur aurait pu être sur le client aussi ; il l'aurait envoyé au serveur de jeu au lancement pour le faire suivre aux autres joueurs. Mais bon, ça fait un peu bricolage.
J'ai aussi envisagé de tout faire gérer par le serveur de jeu mais ça ne tient pas la route. Déjà le serveur de jeu n'a pas de persistance, quand il redémarre il a tout oublié de l'exécution précédente. Ensuite il y a fréquemment deux serveurs de jeu qui tournent simultanément : un pour la dernière version, un pour l'avant-dernière ; parfois même un pour la prochaine version. Donc il aurait fallu un stockage partagé par tous les serveurs, ce qui revient quasiment à faire un serveur business en fait.
Bref, la situation m'oblige à déployer un serveur business.
Il ne reste plus qu'à choisir la techno. Comme Bim! est en C++ c'est la première solution que j'ai envisagée. Côté positif, je connais bien ce langage donc ça sera facile de m'y mettre. Côté négatif, quand on parle de serveur business on parle de service web et de base de données, et quand on parle de service web et de base de données on pense rarement au C++. Les projets qui couvrent ce besoin sont assez niche, ça sent fort la difficulté.
D'ailleurs en parlant de difficulté il faut que je te précise que le web, que ce soit le bout devant ou le bout derrière, c'est pas trop mon domaine, et que je n'ai pas manipulé de base de données depuis un peu plus de 20 ans, pendant mes études. Cette histoire de serveur business est donc une aventure pour moi.
Le deuxième choix de techno se base sur Java, sans doute avec Spring Boot. Je connais le langage, je pense pouvoir me débrouiller avec même si j'aime bien ne pas l'utiliser, et j'ai côtoyé un excellent développeur il y a un peu plus de dix ans qui avait résolu le même problème avec ces technos. Par le biais de revues de code j'avais pu acquérir quelques concepts et je sais donc que ça répond bien à mon problème.
Troisième choix, partir sur une solution en Rust. Déjà ça fait un moment que ce langage me fait de l'œil, et en plus l'idée d'éviter les risques de failles de sécurité sur mon serveur me plaît beaucoup. C'est là-dessus que je suis parti. Par contre je n'y connais rien en Rust…
Architecture du serveur business
Comme tout développeur moderne valant son pesant de cacahuètes face à une techno dont il ignore quasiment tout, j'ai opté pour la seule solution valable. J'ai demandé à un LLM de générer l'application pour moi.
Hey ! Je n'y connais rien, c'est donc forcément mieux que ce que j'aurais fait !
Je blague bien sûr :) Je n'avais pas envie de manquer cette opportunité d'apprendre. Je me suis donc plongé dans le livre Rust - Développez des programmes robustes et sécurisés que j'avais précédemment gagné en participant sur LinuxFr.org. Un livre qui m'a semblé pas ouf quand on a plusieurs dizaines d'années de dev derrière soi mais en même temps, si je me mets dans la tête de moi ado, je pense que c'est aussi bien que les bouquins que je lisais pour apprendre le Pascal. Ça m'a permis de mettre le pied à l'étrier, copier et compiler quelques programmes sans tout comprendre, tester des trucs en modifiant des lignes pour voir ce qui se passe dans tel ou tel cas… Bref, découvrir et apprendre.
J'ai développé une petite preuve de concept dont j'avais parlé ici même et sur Reddit, en demandant des critiques pour essayer de partir avec de bonnes bases. Sur Reddit j'ai obtenu un commentaire du genre « je me suis arrêté après avoir vu quelques unwraps », pas très constructif, et une autre personne m'a envoyé une « analyse » insipide sortie d'un LLM. Je me demande à quoi sert la personne qui m’a envoyé cette analyse, j'aurais pu demander moi-même à un LLM si c'était ce que je souhaitais.
Sur LinuxFr.org j'ai reçu de super conseils de GwenLg, qui m'a accompagné dans la mise en place du projet. Soyons clairs, si quelque chose est bien fait dans le serveur business de Bim! c'est de sa faute :)
Au final ce que l'on a sur le business :
- une base PostgreSQL pour la persistance ;
- du code métier qui fait l'interface vers cette base via tokio-postgres (enfin plutôt via deadpool-postgres, qui va gérer un pool de connexions vers la base) ;
- un service web basé sur axum qui va récupérer ses infos via le module métier.
Une fois cela en place il ne restait plus qu'à décrire les tables, écrire le code métier, les services web… Je n'avais pas trop d'idée de la taille du truc en me lançant, ça s'est déroulé à mesure que je migrais les propriétés du client et du serveur de jeu vers le business. En tout cas ce qui était auparavant sur le client ne m'avait pas l'air gros. Le premier petit jalon est d'envoyer une config au client, avec la liste des serveurs de jeu, les lots de la boutique, les prix des fonctionnalités optionnelles.
Je commence simple avec le serveur de jeu. D'abord il me faut un moyen pour qu'il puisse s'annoncer lui-même auprès du business quand il est en ligne. Ah mais attends, du coup il faut que j'aie un moyen pour autoriser ces serveurs à s'annoncer, pour éviter que d'autres ne s'incrustent. Ben du coup il me faut une interface d'admin en fait… Et puis une passe de nettoyage régulière pour supprimer les serveurs qui ne donnent plus de nouvelles. Ce petit morceau n'est pas si petit.
Ensuite vient la boutique du client. Ah bah du coup il me faut une notion de boutique et une interface d'admin pour ajouter, modifier, lister, supprimer les entrées de cette boutique.
Viennent ensuite les fonctionnalités optionnelles (brouillard de guerre, barrières…). Il me faut une table pour les lister, avec leurs prix. Ainsi que le prix d'un slot supplémentaire.
Et puis il faut que je me souvienne de ce que possède le joueur, donc un inventaire, et aussi un historique des transactions.
Tiens d'ailleurs c'est quoi un « joueur » ? Bon ben il me faut un moyen d'identifier les joueurs, et donc d'avoir une notion de session, et de faire valider les sessions par le serveur de jeu aussi, pour qu'il soit en phase avec le business quand il s'agit d'attribuer les gains à un joueur.
Ah bah il y a ça aussi. Le serveur de jeu doit informer le business quand une partie commence, quand elle termine, et quel est le résultat, afin que ce dernier puisse mettre à jour les stats du joueur et lui donner ses gains.
Petite parenthèse, si tu te demandes comment le business identifie un joueur, c'est un UUID que l'app cliente génère au premier lancement. C'est plus un identifiant d'installation qu'un identifiant de joueur. Cette UUID n'existe que sur l'appareil du joueur et dans une table sur le serveur.
Bon, mis bout à bout, je me retrouve avec 25 tables en tout et une trentaine d'URLs.
Et il eut un nom
Sur la fin de tout ce chantier j'ai implémenté la possibilité de donner un pseudo au joueur ; l'élément qui avait fait pencher la balance vers le développement du serveur business. Mais quel nid à problèmes que de laisser le joueur choisir son pseudo !
Alors évidemment il y a le risque que l'utilisateur entre n'importe quoi. On est sur Internet, je te connais, tu vas mettre des trucs affreux en te croyant futé. J'ai donc mis un moyen pour l'admin de facilement remplacer le pseudo de quelqu'un. J'ai aussi mis un délai entre deux changements de pseudo pour éviter que les joueurs mettent un truc insultant (par exemple) juste le temps d'une partie et que je ne puisse pas voir le problème.
Ensuite il y a la question de la taille du pseudo. Je ne peux pas laisser une longueur libre, sinon ça devient un espace de stockage (et puis ça va tout casser), donc j'ai mis une longueur max arbitraire. J'ai mis une longueur min aussi mais celle-ci me semble moins pertinente. Cela m'a évidemment amené au chouette sujet du nombre de caractères dans une chaîne Unicode, que j'ai lâchement délégué à une lib parce que c'est bon ça va bien deux minutes.
Et puis il y a le sujet de l'affichage du pseudo.
Ça m'est venu quand j'ai entré un premier pseudo contenant un émoji. Avec le moteur que j'utilise sur le client les labels affichant les textes sont paramétrés avec une seule police, qui plus est embarquée dans le package de l'app (i.e. ils n'utilisent pas les polices système). Que se passe-t-il si la police ne contient pas le glyphe ? Rien. Ça n'affiche rien. Rhalala que vais-je faire si l'utilisateur commence à utiliser des trucs que je n'ai pas prévus ? Des émojis, des symboles asiatiques ou arabes… Et puis je ne vais quand même pas embarquer toutes les polices possibles, ce serait idiot.
Je dis que le moteur n'utilise pas les polices systèmes mais ce n'est pas tout à fait vrai. Il y a un mode de label qui utilise bien le système, mais alors je n'ai plus les contours sous Linux, et je ne bénéficie plus de la fonction de réduction de police automatique pour éviter les débordements. Pas ouf.
Bon ben c'est l'impasse. Soit j'abandonne, soit je me retrousse les manches. C'est le début du développement surprise dans la dernière ligne droite : je patche le moteur pour qu'il puisse gérer plusieurs polices par label. Un boulot considérable qui touche à un élément clé et qui impacte donc tout le monde. Des labels il y en a de toutes les sortes, avec des polices bitmap à l'ancienne, avec du FreeType, avec des tonnes d'effets à prendre en compte (contour, ombre, souligné, glow…) et des shaders à gérer. Je découvre dans la foulée que la police émoji que j'utilise (Noto) expose des glyphes bitmap couleur, or les labels du moteur ne prennent en compte que les glyphes vectoriels monochrome. Tout faux. J'implémente donc la gestion des polices TTF bitmap couleur (tout passe par FreeType). Enfin j'ajoute aussi la possibilité d'aller piocher dans d'autres polices et dans les polices système quand un glyphe manque dans la police principale d'un label. C'est pas hyper évident puisqu'il faut déjà trouver les fichiers qui ont le glyphe, mais aussi dans un style proche de l'initial (e.g. gras si le principal est gras). Il y a des outils pour trouver des polices proches selon diverses caractéristiques ; j'ai utilisé Fontconfig sous Linux. Sous Android il y a une API à partir de la version 29 mais comme c'est un peu trop haut pour ce que je vise (23) et que ça ne couvre pas tout mon besoin, j'ai implémenté un truc ad-hoc.
J'ai appris quelques trucs en chemin. Déjà savais-tu que les polices TTF pouvaient avoir des glyphes en SVG ? Il y a une version de la police Noto Emoji qui utilise ça. Je n'ai pas implémenté le support parce que c'était déjà bien assez compliqué comme ça. En fait il y a tellement de trucs dans TTF que je me demande si ça ne serait pas Turing-complet.
Autre découverte, l'existence de GNU Unifont. C'est une police bitmap monochrome avec des glyphes en 8×16 ou 16×16 pixels qui couvrent tout Unicode. C'est tout sobre, tout petit, tout mignon.
Après tout ce travail sur le moteur, j'ai remonté mon patch au projet amont. J'ai un peu hésité, car la dernière fois que j'ai voulu y contribuer j'ai eu une très mauvaise expérience. De l'autre côté je pense que contribuer en amont est La Bonne Chose À Faire™, et puis je n'ai pas envie de maintenir un gros patch comme ça sur mon fork. J'ai donc créé une PR et, quel plaisir, elle a été bien reçue. Elle n'est pas mergée, mais elle est bienvenue.
Quelques mots sur les tests
Côté serveur il n'y a pas tellement d'algorithmique ni même de structures de données hormis quelques commodités. Je n'ai pas grand-chose à tester à part vérifier que chaque URL fait bien ce qu'on lui demande. Du coup en guise de tests j'ai juste des tonnes de commandes curl. Ce qui est cool c'est que ce sont des tests comme si les requêtes venaient du client ; c'est rassurant. En général j'ai un endpoint qui me permet de vérifier le résultat, autrement je fais aussi quelques requêtes en base. Ça m'a bien servi pendant le dev. Comme d'hab', ça me casse les pieds d'écrire les tests, mais je suis bien content de les avoir écrits quand je fais de grosses modifs dans le code principal.
Le plus pénible dans tout ça est qu'il faut lancer un serveur Bim! et un serveur PostgreSQL. Et c'est encore pire côté client. Avant j'avais besoin de lancer un serveur de jeu pour tester l'app, maintenant il faut en plus que je lance le business et que j'attende que le serveur de jeu s'y enregistre. Un peu galère, heureusement j'ai pu automatiser tout ça.
Côté client c'est assez compliqué de tester toutes les requêtes vers le serveur, par conséquent j'ai des mock-ups et je teste surtout la sérialisation des données envoyées et hypothétiquement reçues. J'ai aussi des tests d'UI qui passent par tous les écrans, avec ça je pense couvrir une bonne partie de l'expérience utilisateur. J'ai juste zappé les tests automatiques sur l'envoi de l'inventaire historique parce que flemme. Et devine ce qui a crashé lorsque j'ai entamé les tests manuels ? Et ouais… En plus ça m'a bien pris la tête de trouver la cause. J'aurais dû écrire plus de tests.
Quelques mots sur Rust
J'en profite pour te faire un petit retour de nouveau venu dans le monde de Rust. Il y a des trucs cool, des trucs moins cool, mais globalement je suppose que ce qui me déplaît est surtout lié au fait que je viens de débarquer et que je ne connais rien à l'outil.
Le fameux borrow-checker est vraiment super. C'est bien galère de le satisfaire, mais on voit vite que ça vaut le coup.
La gestion des erreurs me semble inutilement précise. La plupart des erreurs ne sont pas gérées et sont juste remontées à l'appelant. Ça fait plein de gestion et de transformations d'erreurs tout ça pour qu'à la fin on fasse juste un test d'existence équivalent à « y a-t-il une erreur ? ». Pas très utile. Je pense que des exceptions auraient très bien fait le job, mais il n'y en a pas en Rust. C'est dommage parce qu'après des décennies à entendre l'industrie répéter avec insistance que les exceptions c'est nul pour les perfs quelqu'un s'est décidé à vérifier et a découvert qu'en fait c'était super. En plus des perfs ça allégerait aussi les interfaces. (Oui je sais que les exceptions c'est nul aussi pour la lisibilité et que ça cache les problèmes :))
Pas lié à Rust directement mais les outils que j'ai choisis imposent la programmation asynchrone avec tokio. Ça fait que j'ai des awaits partout, c'est un peu lourd. J'aurais préféré une solution synchrone avec un parallélisme plus haut niveau. Si t'ajoutes à ça les points d'interrogation pour remonter les erreurs, ça.await? devient.await? risible.await?
La forme if let Some(x) = y est vraiment agréable et je me suis surpris à retravailler des fonctions pour tomber là-dessus plutôt que d'avoir des unwrap().
Beaucoup trop d'appels à l'aide autour de Rust reçoivent des réponses rabaissantes du type « ce n'est pas ce que tu veux faire » ou le classique « les ordis d'aujourd'hui sont assez puissants ». J'ai l'impression d'être revenu des années en arrière. Quand quelqu'un te demande comment faire quelque chose, dis-lui d'abord comment faire, et seulement ensuite tu peux lui expliquer pourquoi tu penses que c'est une mauvaise façon d'aborder le problème.
Les temps de compilation sont plutôt longs et le fait de ne pas pouvoir compiler des fichiers individuellement est vite handicapant. De plus Rust n'a pas grand-chose à envier à C++ en termes de verbosité des messages d'erreur et pour peu qu'il y ait un Trait mal placé c'est vite l'enfer à démêler malgré les suggestions du compilateur.
Je suis assez mitigé sur Cargo qui me télécharge tout Internet pour tirer les dépendances, ainsi que sur cette manie de faire des libs minuscules pour chaque chose. D'un autre côté ça a l'air plutôt bien fait et plus robuste que ce qu'on peut voir côté NodeJS, mais ne risquons-nous pas nous aussi de chopper des cochonneries à tirer des arbres de dépendances comme ça dans tous les sens ?
Voilà c'est ce qui me vient en tant que nouveau. Peut-être que la prochaine fois, avec un peu plus d'expérience, j'aurais changé d'avis et je te dirai l'inverse :)
Le code
Ah oui au fait, le code est ici. C'est de l'AGPL3. J'ai beaucoup hésité à tout mettre dans le dépôt de Bim! car ça serait plus simple pour les tests, mais en même temps le fait d'avoir un dépôt à part m'encourage à penser le serveur comme un véritable projet indépendant. Je pense que c'est mieux pour le logiciel mais je ne m'interdis pas de fusionner l'ensemble à l'avenir.
Quelques stats
Comme à l'accoutumée on va faire un petit point statistiques de jeu. La précédente version introduisait les bots pour que les joueurs puissent jouer même si le serveur de jeu ne leur trouvait pas d'adversaire. Cela a-t-il changé quelque chose sur l'activité ?
Si on regarde le nombre de parties par heure, ça me semble assez clair qu'il y a une augmentation par rapport à ce qui était présenté dans le précédent journal avec des pics réguliers au-delà de 30.

Côté sessions il y en a moins de 20 par heure, à part début septembre où il y a eu un pic énorme pour une raison que j'ignore, sans lancer de parties apparemment. Ça a l'air de venir des US, mais je n'ai pas cherché plus que ça.

En termes de répartition du nombre de joueurs par partie, en agrégeant les parties depuis le déploiement de la version 16, le 20 avril, jusqu'à aujourd'hui (environ 6 mois) on en est à peu près à :
| Nombre de joueurs | Nombre de parties |
|---|---|
| 1 | 51 508 |
| 2 | 4049 |
| 3 | 330 |
| 4 | 34 |
Des scores à la fois plaisants et un peu décevants. Plaisants parce que ça fait pas mal de parties mais décevants parce que le nombre de parties entre humains est proportionnellement plus bas que ce qu'on avait dans le journal précédent. À l'époque, sur trois mois, c'était 4150 parties à deux, 916 à trois, et 293 à quatre. Je ne sais pas trop quoi en penser. J'imagine que c'est un bon score quand même et que mécaniquement s'il y a plus de joueurs ils vont bien finir par tomber les uns sur les autres.
Enfin, on finit avec la répartition par pays :

Et la même donnée vue par PostHog, pas tout à fait identique, mais proche :

Ça sera tout pour cet article vachement technique. On se retrouvera pour la prochaine version avec, j'espère, plus de nouveautés visibles pour les joueurs :)

Envoyer un commentaire
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.