Journal Sortie de Bim! en version 14, avec des barrières

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
8
28
jan.
2026

Sommaire

Cher journal,

Quel plaisir de te retrouver. J'ai sorti deux versions de Bim! depuis la dernière dépêche. J'ai donc plein de choses à te raconter. Au programme cette fois-ci : un peu de graphisme, une nouvelle fonctionnalité de game play, une enquête sur un build en échec mais pas pour la raison que je pensais, des tests d'UI, des optims, une source inattendue de revenus, un peu de jardinage, et comme d'habitude des stats au sujet du jeu et des joueurs.

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.

Nouvelle icône

Ce fut pour moi une grosse surprise, durant le développement de la version 13, de découvrir que l'icône que j'avais dessinée un peu rapidement histoire d'avoir un visuel allait finalement rester de manière pour ainsi dire définitive. Et quand je dis « découvrir » je parle plutôt d'une sorte d'acceptation à l'issue d'une période de déni. En effet, force est de constater que je n'ai ni le temps ni l'intention de remplacer l'icône dans un futur proche.

Le problème avec cette révélation est que maintenant que je sais que l'icône va rester je suis assez gêné par ses petits défauts. Autant pour les graphismes de jeu j'ai tendance à apprécier l'aspect approximatif et humain d'un graphisme fait main, autant pour l'interface utilisateur et l’iconographie je préfère un dessin précis sans doute plus lisible.

Et donc cette icône avec ses tracés irréguliers, qui est la première chose que l'on voit pour lancer le jeu, voire pour installer le jeu, je ne la trouve pas au niveau. Dans un PlayStore où tout est formaté et précis, je trouve qu'elle fait un peu tache. Je suis donc repassé sur les tracés pour les refaire avec les outils courbes de GIMP, tout en me disant que rhôlôlô j'avais quand même bien bâclée la première icône.


Zoom sur quelques retouches de l'icône. L'ancienne version en haut, la nouvelle en bas.

Ajout des barrières dans le jeu

C'est la grande nouveauté de jeu de la version 14, des petites barrières qui bloquent les joueurs mais laissent passer les flammes. Ça met un peu de piquant dans les parties en demandant de changer de stratégie pour poser ses bombes. Rien de plus frustrant que de voir un bonus apparaître de l'autre côté d'une barrière et de se le faire piquer avant d'avoir ouvert le chemin !

Ça ressemble à ça :

Illustration Intégration

Tu vas me dire que c'est quand même vachement détaillé comme dessin pour un truc qui fait un millimètre sur deux quand il est affiché dans le jeu, et t'auras bien raison. Je n'ai pas d'excuses.

J'ai longuement hésité sur l'aspect et le fonctionnement. Au début j'étais parti sur une grille qui occuperait une case entière, mais en la concrétisant sur papier ça m'a semblé un peu trop massif, et cher payé, de perdre une case pour cela. J'ai donc transformé ça en un grillage en bord de case, ce qui fonctionne bien quand le grillage est face à nous, mais plus du tout quand il est sur le côté.


Premiers croquis.

Un peu coincé par les premiers designs je change de plan. Est-ce que la solution ne serait-pas tout simplement des plots ? Je tente quelques croquis et ça semble plutôt pas mal. C'est clairement plus lisible pour les côtés gauche et droit des cases, et j'en viens à me demander à nouveau si ça ne serait pas aussi bien de les mettre au milieu des cases. En tout cas ça serait beaucoup plus simple en termes d'implémentation ! La version en bord de case me demande de revoir le code de déplacement du joueur, ce que je n'ai pas très envie de faire car c'est galère à tester.


Croquis pour la variante en forme de plots.

Ci-dessus des croquis d'étude pour les barrières. Comme tu l'as deviné avec les captures précédentes j'ai quand même opté pour la variante en bord de case !

L'algo de placement des barrières

Le placement des barrières m’a semblé être un intéressant problème combinatoire. Peut-être trouveras-tu une meilleure méthode que celle que j'ai implémentée ?

Les barrières sont posées sur un côté intérieur des cases (et non pas à la frontière qui sépare les cases). Le but du jeu est de les placer de manière à ce que :

  • toute l'aire de jeu soit accessible ;
  • pas de barrières côte à côte : si on a une barrière sur le côté droit d'une case, on n'en met pas sur le côté gauche de la case opposée ;
  • le placement doit contraindre tous les joueurs de manière égale ;
  • pas de barrières dans les cases adjacentes à la position initiale des joueurs (les coins de l'aire de jeu).

L'aire de jeu est une grille de 13 cases de large sur 15 de long. Certaines cases, noires sur l'image ci-dessous, sont déjà occupées et ne peuvent être utilisées pour des barrières ni pour circuler.


Schématisation de l'aire de jeu.

Pour l'égalité des contraintes, j'ai choisi de faire un placement aléatoire dans le quart haut-gauche de l'aire de jeu puis de projeter le résultat de manière symétrique sur les trois autres quarts. Il faut faire attention car les dimensions de l'aire de jeu ne sont pas paires, donc la symétrie recouvre une colonne. Histoire de ne pas trop charger l'aire de jeu je mets entre 2 et 5 barrières par quart.

De plus, pour des raisons esthétiques et de facilité de circulation j'ai choisi de ne pas poser de barrière dans les intersections. Il faut qu'il y ait une case noire au-dessus et en dessous, ou à gauche et à droite.


Positions de barrières valides en bleu et invalides en rouge. Il faut imaginer que le motif se répète.

J'ai longtemps cherché s'il y avait une manière constructive de poser les barrières, une propriété que je pourrais mettre à jour à chaque fois et qui réduirait les possibilités pour les suivantes, de manière à poser n barrières en n étapes. Je n'ai rien trouvé de tel.

Au final j'ai dû me résigner à faire une recherche de chemin. Je prends une case au hasard, j'y mets une barrière au hasard, puis je vérifie que je peux atteindre l'autre côté à partir de la case en cours. Si c'est faisable, la barrière reste, sinon je l'enlève.

Au départ j'ai écrit une fonction récursive pour faire la recherche de chemin comme un cochon, ça permet de valider l'idée, mais évidemment comme tout cela est combinatoire j'ai bien envie de soigner l'implémentation et surtout d'avoir une estimation du temps que cela prend, même si avec les dimensions d'aire de jeu il y a peu de risques que ce soit long.

J'ai donc écrit un benchmark qui lance l'algorithme de recherche de chemin pour chaque case et à destination de chaque case, sans barrières.

Dans l'implémentation suivante j'enlève la récursivité et je stocke dans un std::vector les cases qu'il me reste à visiter. Sans ordre particulier, pour chaque case je mets à la fin du tableau celles des 4 voisines qui n'ont pas encore été vues, puis je traite celle en fin de tableau, jusqu'à atteindre la destination ou vider le tableau. Le benchmark me dit 11 ms en moyenne sur mon laptop, et 22 ms. sur mon téléphone. Ce serait déjà suffisamment rapide.

Pour la seconde implémentation je maintiens les coordonnées dans le std::vector dans l'ordre décroissant du carré de la distance à la destination. Le benchmark passe à 3 ms. sur mon laptop et 5 ms. sur le téléphone. Mieux !

Pour finir je tente un tri décroissant en utilisant la distance de Manhattan. C'est un tout petit peu moins rapide alors je reste sur la seconde implémentation.

Le résultat est très plaisant, la symétrie donne de jolis motifs. De plus cette excursion dans le domaine de la navigation dans l'aire de jeu donne envie de créer des robots contre lesquels jouer. Ou pour les tests, j'en aurais bien besoin !

Le mystère du build F-Droid en échec

Lors du développement de la version 13 il m'est arrivé un problème bien énigmatique.

Tout a commencé bêtement en mettant à jour le SDK Android et le plugin Gradle. C'est un truc tout simple à faire. J'ai modifié les versions dans les scripts et j'ai vérifié que cela fonctionnait sur ma machine. Puis j'ai poussé tout ça sur la CI et là, paf ! Le build dans l'environnement F-Droid est en échec. Il me sort un ModuleNotFoundError sur un certain requests.

Pour info le build F-Droid sur ma CI fait un build Android en utilisant l'image Docker utilisée par F-Droid pour leurs builds. Ainsi je peux voir en amont les problèmes d'intégration et les corriger avant la release.

Comme je ne comprends pas pourquoi ça fonctionne chez moi et pas dans les pipelines GitHub, je fais du remote debugging en ajoutant un commit qui fait un cat sur le script gradlew, que je pousse sur le dépôt. Le pipeline échoue et je peux voir que gradew est écrit en Python. J'aurais parié que c'était du Bash ! D'ailleurs je vérifie sur ma machine et c'est bien du Bash.

Je ne comprends rien, il est tard, dans le doute j'ajoute l'installation de python3-requests dans les étapes du build, sans effet. L'erreur est toujours là.

À ce moment je m'approche du désespoir. Je teste à nouveau sur ma machine mais cette fois depuis une image de l'environnement F-Droid. Le build passe sans encombres.

Je retourne voir les logs sur GitHub et je tilte sur le SHA de l'image F-Droid. En fait GitHub télécharge systématiquement la nouvelle image correspondant au tag de l'environnement. Il ne m'était pas venu à l'idée que le tag pouvait pointer vers une autre image. Quelle idée de donner le même nom à deux choses différentes.

Sur ma machine l'image de l'environnement F-Droid était déjà disponible et correspondait à une ancienne version. Je tente un docker pull puis je relance mon test. Bingo, je reproduis l'erreur.

En creusant un peu j'apprends que F-Droid a son propre gradlew écrit en Python. Cela dit je ne comprends pas pourquoi ce script intervient dans mon build.

Je continue de creuser et je découvre qu'ils ont ajouté un lien /usr/local/bin/gradle -> script custom.

De mon côté, le script de build lance gradle directement, or par le jeu du PATH celui-ci est maintenant résolu vers /usr/bin/gradle. Il s'avère de plus que mon build active un environnement virtuel Python dans lequel requests n'est pas installé, c'est pour cela que le module est not found.

Mais quel rapport avec la mise à jour du SDK Android ? Et bien aucun, c'est juste un coup de pas de chance. Il s'avère que l'image F-Droid a été mise à jour peu avant que je mette à jour le SDK, et voyant une erreur sur un build Android j'ai immédiatement pensé à un problème de mon côté. Ça m'a lancé sur une mauvaise piste qui a bien compliqué la recherche de la véritable cause.

Introduction des tests d'interface utilisateur

Bien que je n'aime pas trop écrire des tests (c'est pas très fun), je prends soin quand même d'en avoir, avec plus ou moins de rigueur. D'ailleurs lorsque j'ai implémenté les barrières j'ai un peu payé la note de nombreux tests que j'avais omis précédemment. Par exemple j'ai découvert qu'il n'y avait aucun test qui vérifiait que les joueurs perdaient quand ils étaient en contact d'une flamme. La base.

Les tests unitaires sont relativement faciles à mettre en place et l'ECS est assez pratique pour cela puisqu'on peut n'instancier que les systèmes à tester. Pas besoin de créer tout un monde. Bon parfois on se demande si le test correspond bien à une situation de jeu, mais ça vaut le coup quand même.

Un poil plus difficiles à gérer, j'ai des tests qui instancient un serveur et des clients puis qui exercent le protocole de communication. Je ne pense pas qu'on puisse appeler cela des tests d'intégration car il n'y a rien qui ressemble à un déploiement, mais c'est plus compliqué qu'un test unitaire. Ceux-là sont assez galère car la communication entre le client et le serveur passe par une socket et donc il y a du délai et des pertes. Un truc qui m'embête avec ces tests aussi est que le port est codé en dur, donc pas moyen de lancer les tests en parallèle, le port ne sera pas disponible.

Pour tous ces tests je prends soin de mettre autant de code que possible hors UI, mais à un moment il faut bien envisager de tester l'UI aussi. Après tout c'est un gros morceau et c'est la partie la plus visible pour l'utilisateur, donc si ça ne fonctionne pas ça se voit bien.

D'ailleurs, pour l’anecdote, après la publication de la version 12, j'ai lancé le jeu avec l'intention de modifier mes paramètres. Je clique sur l'engrenage et là, c'est le drame. L'écran de paramètres s'affiche mais rien ne réagit. Catastrophe. C'est ce qui a motivé l'ajout d'une série de tests d'UI.

Dans des projets précédents je faisais quelques tests d'interface, très simples, notamment avec AndroidViewClient (qui est un excellent outil). Ce que j'ai retenu des tests d'UI est que c'est terriblement lent, notamment parce que c'est beaucoup basé sur des timeouts. On simule un clic quelque part, on attend suffisamment longtemps pour être sûr que l'action se termine, et on passe à la suite. D'ailleurs parfois l'action est un peu plus lente que prévu, alors on augmente le délai et tout devient encore plus long.

D'autre part on doit parfois simuler les clics en coordonnées, absolues ou avec un peu de chance en ratio, ce qui fait que lorsqu'on change les boutons de place les tests échouent. Sur ce point il y a des outils qui permettent d'interagir par identifiant de contrôle plutôt que par coordonnées. C'est le cas notamment d'AndroidViewClient. Mais pour mon cas d'un programme en natif, ça ne va pas aider. Et puis de toute façon je n'ai pas envie de lancer un émulateur Android ni de connecter un appareil juste pour lancer les tests.

Au final j'ai implémenté quelques outils dans le jeu pour pouvoir tester l'interface, et cela directement avec le client sous Linux. Je peux lui passer un Json qui décrit une série d'actions du genre cliquer sur un élément, attendre un événement, prendre une capture d'écran. Forcément j'ai ajouté dans le jeu ce qu'il faut pour pouvoir identifier les éléments et émettre des événements bien sentis. C'est un peu intrusif mais je pense que ça vaut le coup, en particulier ces événements qui me permettent de me passer de timeouts.

Par-dessus cela j'ai mis quelques scripts Bash pour lancer les tests et j'ai écrit des scénarios pour interagir avec absolument tous les éléments de l'interface. C'était bien bieeeeeen galère à faire, en particulier les tests qui font intervenir des événements du jeu en ligne tels que les écrans de fin de partie. Pour cela, pas le choix, je lance un serveur en local et plusieurs clients qui vont matcher ensemble.

En plus de la couverture de test cet outil m'a aussi permis de lever un angle mort dans la gestion des traductions. Grâce à quelques scénarios et quelques scripts je peux générer des captures de tous les écrans dans toutes les langues et ainsi vérifier que les textes s'affichent correctement et ne débordent pas des contrôles. J'utilise même l'outil pour générer les captures à destination des stores, que je faisais à la main auparavant.

L'ensemble des tests s'exécutent en quelques minutes, ce qui est bien plus rapide que les tests manuels tout en couvrant plus de cas. Gros gain de temps et de précision. Le seul bémol est qu'ils nécessitent un serveur graphique, ce qui m'empêche de les lancer dans la CI, mais j'ai bon espoir de pouvoir adapter Axmol pour qu'il fonctionne en mode headless, ce qui résoudrait ce problème.

Réduction du temps de chargement

En décembre dernier j'avais clairement envie de tâches techniques. J'ai passé pas mal de temps à jouer avec Tracy et à étudier des parties d'Axmol en quête de choses à optimiser, notamment sur le temps de chargement de l'application.

En petite amélioration facile et sans risque, j'ai ajouté quelques threads de chargement de texture à Axmol. Le chargement était déjà fait en parallèle auparavant mais avec un seul thread qui s'occupait de charger les images puis transférait ensuite les buffers au thread principal pour créer les textures. Du coup si plusieurs fichiers devaient être chargés ils étaient traités en séquence dans le thread de chargement. Dorénavant il y a autant de threads de chargement de fichiers qu'il y a de cœurs. En pratique je n'ai pas vu de grande différence, il faut dire que le jeu n'utilise qu'une quinzaine de textures et une large majorité d'entre elles sont très petites.

Ce que j'ai vu, par contre, grâce à Tracy, est que certaines transitions d'écrans tombaient sous les 60 fps, apparemment à cause de la création de labels. D'ailleurs la création de labels remonte aussi comme la principale activité du chargement du jeu. Dans la capture ci-dessous le chargement correspond au segment « locked». On y voit une partie « create_ui » qui instancie l'interface, et dans la pile de laquelle on trouve des updateContent plus ou moins longs. Au plus bas de la pile, si on zoomait, on verrait des appels à FT_LoadGlyph de FreeType.

J'ai passé énormément de temps à tester des combinaisons de paramètres de FreeType pour accélérer le chargement, sans succès. Il semblerait que la lenteur de FT_LoadGlyph soit une fatalité. Enfin… Je découvrirai plus tard que cette soit-disant lenteur était en fait due à un enchaînement de mauvais paramètres. Il s'avère que FT_LoadGlyph récupère les infos du glyphe dans le fichier de police, via des fonctions de lecture passées à l'initialisation de la bibliothèque. Sur Android on lui passe donc des fonctions qui vont aller chercher les fichiers dans le dossier des assets. Or les fichiers de ce dossier sont par défaut compressés ! Forcément si chaque lecture doit passer par une étape de décompression, ça devient très lent. Après avoir correctement paramétré mon build pour éviter ces décompressions systématiques, le temps de chargement a été réduit d'une seconde.

À noter qu'il y avait deux solutions pour ce problème. Soit paramétrer le build pour ne pas compresser les polices, auquel cas l'APK est un peu plus gros, soit paramétrer Axmol pour décompresser le fichier en amont, auquel cas ça consomme un peu plus de RAM. J'ai choisi la deuxième solution en me disant que tout le monde apprécierait un téléchargement plus léger tandis que personne ne se plaindrait d'un léger pic de consommation de RAM pendant le chargement.

Un autre problème des labels se trouvait dans l'algorithme de réduction automatique de la taille de la police quand le label déborde de son contenant. L'implémentation initiale faisait une réduction de la taille de 1 à 1 jusqu'à ce que ça rentre. J'ai remplacé ça par une dichotomie, ce qui m'a fait gagner encore pas loin d'une seconde !

La thuuuuuune

Ça y est, j'ai enfin gagné de l'argent grâce à Bim!. Enfin… Pas exactement. J'ai surtout gagné un peu d'argent en contribuant à Axmol dans l'intérêt de Bim!…

Axmol a un programme de récompenses pour les contributeurs via les fonds gérés pour eux par OpenCollective. En gros on fait une demande de remboursement dans l'interface d'OpenCollective en référençant le travail fait dans Axmol, par exemple une PR, puis l'équipe d'Axmol donne une valeur en dollars à cette contribution et nous fait un virement. Alors bien sûr on ne peut pas savoir en amont le montant qu'on va recevoir, ça dépend des responsables du projet et certainement des fonds disponibles, mais le principe me semble pas mal.

Dans mon cas j'ai donc pu récolter 50 € pour un total de quatre PR.

[Insérer une pause dramatique.]

Ça m'a posé un petit problème de conscience. Comment se fait-il que je bénéficie gratuitement de cet excellent projet, complètement géré par des tiers, et qu'en plus ils me donnent de l'argent ? Ne serait-ce pas plus logique que ce soit moi, consommateur, qui contribue au projet ? Je contribue déjà techniquement, mais ça me semblerait logique que s'il y a transfert d'argent ce soit dans l'autre sens.

Cela dit, l'idée de récompenser financièrement les contributeurs est top. Si vous voulez vous faire un peu d'argent de poche j'aurais des pistes à vous donner ! Par exemple je rêve d'un éditeur d'animations à la Spine ou feu DragonBox mais libre et directement intégré à Axmol. Ou encore faire un travail de réduction des temps de build, franchement ça serait top.

Outils de jardinage

J'ai eu connaissance de deux événements sur des thèmes qui collent à Bim! ces derniers mois. J'ai postulé, et je me suis pris trois râteaux :/

Le premier était le Stunfest. Ils faisaient un appel à participations pour les créations indépendantes et amateur ; c'est parfait, c'est exactement moi ! Malheureusement ils ne m'ont pas retenu.

Le deuxième était le FOSDEM qui propose une série de présentations «Gaming and VR devroom». Le FOSDEM c'est la conférence des développeurs de logiciels libres. Du logiciel libre et des jeux ? C'est exactement moi ! Malheureusement ils ne m'ont pas retenu non plus.

Le troisième était aussi le FOSDEM. Je n'ai pas été accepté pour un long format mais ils proposent aussi des présentations plus courtes. J'ai donc re-soumis, mais à nouveau ça a été refusé.

Les messages de refus sont un peu génériques, c'est toujours une histoire de choix difficile car il y a eu beaucoup de propositions. Je le crois volontiers, néanmoins j'aimerais avoir un retour personnalisé un peu comme on peut le faire suite à un entretien d'embauche. Par exemple je vois que les jeux présentés au Stunfest sont très bien finis graphiquement, et sont même déjà sur Steam. Forcément à côté je suis à un autre niveau dans la catégorie amateur. Aurais-je été mieux placé avec un jeu plus fini ? Je ne le saurai jamais.

Le FOSDEM c'est un peu la double peine. Présenter là-bas m'aurait donné l'occasion de discuter avec d'autres gens du logiciel libre, et sûrement de croiser des gens LinuxFr. Ça m'aurait fait une bonne excuse pour y aller, mais sans présentation c'est dur de justifier le coût du voyage.

Bon bah tant pis !

Des stats

Je t'avais raconté précédemment l'ajout de statistiques côté serveur pour suivre l'évolution de l'activité sur la dernière heure, les 24 dernières heures, et les 30 derniers jours. Et bien une partie de ses stats est maintenant visible en ligne. Les deux graphes présentent respectivement le nombre de parties et le nombre de sessions dans la dernière heure, sur les 60 derniers jours.


Nombre de parties par heure au moment où j'écris ces lignes.


Nombre de sessions par heure au moment où j'écris ces lignes.

Côté technique c'est vraiment basique. Un script lancé par cron va récupérer les stats du serveur toutes les heures, extrait les valeurs sur l'intervalle de temps désiré, et met ça sur le serveur web. La page HTML du lien plus haut récupère tout ça puis le donne à manger à D3 pour afficher les graphes.

D'autre part, grâce à PostHog je peux voir le taux de joueurs qui lancent l'application et rejoignent une partie :


Taux de conversion des joueurs vers l'écran de jeu, sur 7 jours.

La capture a été prise lors d'une période de forte activité, dans les jours suivant la sortie de la nouvelle version. Même dans ces conditions on n'a qu'un tiers des sessions qui se retrouvent en match.

Enfin on a aussi accès au nombre de sessions par jour :


Nombre de lancements par jour sur 90 jours.

Il faudrait presque faire une livraison par jour tellement ça amène du monde :) J'imagine que c'est la notif de mise à jour par F-Droid qui est responsable de cela car je ne communique quasiment pas sur les sorties et qu'il y a très peu de joueurs venant du PlayStore. Ça donne envie de créer des rendez-vous pour faire venir les joueurs !

En termes de répartition du nombre de joueurs par partie, on en est à peu près à (sur 45 jours) :

Nombre de joueurs Nombre de parties
2 1569
3 408
4 105

Et enfin, pour la répartition des joueurs par pays, on a :

Mmmh grosse remontée de la Russie apparemment, mais vu la tête des logs il doit se passer des trucs pas très nets. À creuser !

Ce sera tout pour cette fois. À bientôt en match :)

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.