Focus TourDeJeu

par Le Caphar Développeur du jeu Tératogénèse

[Ceci est la version complète imprimable]

Métaphore botanique
Tout programmeur en herbe, alors qu'il plante les petites graines de ce qui deviendra un grand succès du jeu en ligne, conçoit un jour des arborescences interminables et des aventures merveilleuses, aux graphismes tapageurs servis par une interface d'une intuitivité inégalable. Et lorsqu'il lance ses premiers essais sur un modeste hébergeur gratuit, ébahi par la splendeur nue de son premier echo "Bonjour."; en Times noir sur fond blanc, il se rend compte - mais vaguement, sans trop vouloir y penser - que c'est assez lent quand même. Alors il fait pousser ses fonctions, arrose sa base de données, et quand il a obtenu une véritable flore, il invite quelques braves passants à venir cueillir quelques fruits. Oui mais voilà... les fruits bourrés d'OGM mesurent trois mètres et pèsent six tonnes. Et les quelques badauds qui s'aventurent dans le jardin ressortent bredouilles et passablement énervés.

Cette petite métaphore botanique, pas très rigoureuse, nous a tout de même conduit au coeur du sujet : un jeu en ligne trop lourd, si beau soit-il, si fascinante soit son intrigue, fera fuir les visiteurs, qui n'ont pas que ça à faire d'attendre le chargement de 1Mo pour déplacer leur monstre d'une case. Avec un peu de chance, vous arriverez à capturer quelques joueurs. Soit, mais les ennuis ne font que commencer. Untel va vous dire que les pages ne se chargent pas assez vite, donc il clique plusieurs fois, rechargeant systématiquement le début de la page d'action, ce qui finit - selon l'ordre dans lequel vous avez placé les évènements - par lui faire perdre tous ses points ou au contraire effectuer des tas d'actions gratuitement.

Un jour, vous réunissez enfin les fonds pour que votre jeu dispose d'un serveur. Rapide, fiable, pas cher ; c'est le rêve. Oui mais voilà : au bout d'un mois, le commercial vous appelle et vous signale que vous "abusez du trafic illimité" (rigolez pas, c'est ainsi que va le commerce) qui vous est octroyé, et que pour gérer vos 40 GO de données échangées par jour vous devrez hypothéquer la maison le mois suivant.

"Fichtre", se dit-on dans ces cas-là. "Je n'étais pas fait pour l'herboristerie virtuelle."

Et là je vous dis : "Quid de ces récriminations ? Mettez-vous au travail ! Un jeu c'est RIEN." Ou presque. En termes de données, la plupart des jeux online pourraient tenir sur une disquette, et les calculs qu'ils effectuent pourraient être faits par un enfant de CE2 (on va dire une classe de CE2 pour les plus complexes). Comment alors parviennent-ils à mettre à genoux un serveur ? Parce qu'ils sont trop expansifs. Ce que je veux inculquer aux programmeurs débutants, c'est à ALLEGER leur jeu, par diverses petites ruses que j'ai apprises au fil des nuits sans sommeil, l'oeil morne rivé sur d'impénétrables lignes de code. Et si je pouvais donner quelques trucs aux vieux routards qui traînent, je n'en serais que plus fier.

Compactez vos données
Le premier réflexe que devrait avoir un concepteur de jeu, c'est de tester son jeu sur un modem 14K. Encore que ce soit difficile à trouver de nos jours, l'expérience est enrichissante. Elle permet de mesurer la quantité de données qui sont envoyées au joueur par le jeu.

Il faut dès à présent énoncer une règle ESSENTIELLE dans la conception d'un jeu : estimez toujours la quantité de données affichées en fonction du temps moyen que devra passer le joueur sur la page de jeu. S'il doit s'arrêter pour réfléchir deux minutes entre chaque action le temps que le jeu se charge, le niveau de réflexion stratégique du jeu a intérêt à être élevé. Un jeu de réflexion peut impliquer des éléments graphiques plus complexes qu'un jeu d'action. Et l'efficacité du jeu impose des compromis ou des ruses de sioux pour s'accomoder de graphismes complexes.

Oubliez votre fond d'écran en 1280x1024
C'est dur à vivre, mais votre fond d'écran représentant un dragon rouge terrassant un quelconque Saint Michel va devoir passer à la poubelle. Certes il ne pèse que 130ko en JPEG. Mais quand 1000 joueurs viennent jouer tous les jours, ils téléchargent grosso modo 130 mégas de ce fond d'écran par jour (en comptant le cache du navigateur). Soit 4GO par mois, plus que le plafond de 3GO qu'autorisent certains hébergeurs. Les solutions pour vivre sans image de fond géante sont multiples. La première, c'est de décomposer l'image, par exemple entre arrière-plan et avant-plan. Si le fond de l'image est un dégradé rouge/vert du plus bel effet, il sera sans doute intéressant de compresser ce fond indépendamment en JPEG et de surimposer le dragon en GIF transparent. De toute manière, un fond d'écran n'a pas besoin de sauter aux yeux puisqu'il est destiné à décorer le fond de l'écran.

La variante, c'est la texture "marbre" qui pèse 30ko. Certes pour avoir un motif pas trop répétitif, l'image doit être grande, mais il est impensable que ce fond ne se réduise pas à quelques ko (en GIF 12 couleurs ou en JPEG un peu compressé). C'est une question de compromis : il faut accepter une petite perte de couleur dans le fond d'écran (qui est d'ailleurs souvent caché par les boutons, cadres et autres textes) quand elle permet de diviser la taille par 6. Les joueurs vous en seront éternellement reconnaissants. Il y a plein de logiciels et d'utilitaires qui permettent de gérer la compression à l'octet près (Photoshop, Paint Shop Pro, JPEG Optimizer...), ne vous en privez pas.

D'une manière générale, décomposer une image en petits éléments permet d'économiser des octets parce que la compression fonctionne très bien sur des éléments simples. Il suffit de les assembler ensuite dans les pages HTML constituant le jeu. Le HTML permet de surimposer le fond d'écran du BODY, puis le fond détouré (en GIF transparent) d'un TABLE, par exemple, et enfin une IMG éventuellement transparente, elle aussi. Et encore je ne parle pas de LAYERS qui permettent d'empiler les couches d'images. De quoi décomposer en petits morceaux la plupart des éléments visuels de votre jeu. Mais l'essentiel, je vous le rappelle, c'est d'être modestes : si le logo 3D que vous avez fait ne se compresse pas en dessous de 20ko, JETEZ-LE ! Ne surchargez pas vos pages avec cet élément encombrant, qui ne sert pas le jeu.

Le texte aussi
On a tendance à penser que le texte a peu d'importance par rapport aux images, parce qu'il ne pèse pas lourd et qu'on ne peut pas le compresser. Il ne mériterait pas qu'on se prenne la tête à l'optimiser. Moi je dis : "Que nenni ! Détrompez-vous !" Parce que, contrairement aux images, qui peuvent se mettre en cache sur le navigateur, le texte généré par PHP est RECHARGÉ dès qu'il y a un changement sur la page. Et puisqu'on cause de jeux en ligne, à chaque changement de page il est censé y avoir rechargement des données. Ce qui signifie que la page de 30ko (et je reste modeste ; ça monte vite avec quelques tableaux et une mise en page complexe) va être chargée, disons une quinzaine de fois par chaque joueur et que 1000x15x30ko par jour ça fait beaucoup d'octets quand même. Dans un jeu de plateau constitué de cases (type Nainwak), le navigateur doit charger par exemple 8x8 = 64 cases dotées chacune d'un fond d'écran et d'images, et éventuellement de liens et autres mouseover. Dans ce type de structure, le moindre caractère de texte est précieux, puisqu'il sera envoyé soixante quatre fois au navigateur du joueur à chaque action.

Il y a des manières diverses de compresser le texte, dont l'efficacité dépend essentiellement du type de données présentes sur la page. La première préoccupation, c'est de surveiller la taille des balises HTML. Utilisez les feuilles de style ! Il faut bannir les mises en forme de type <FONT> alors qu'il existe les <TD class="style"> (ou les <SPAN class="style"> si vous n'avez pas de tableau) bien plus économiques. Il est trop facile de créer un code monumental à partir d'une simple ligne PHP répétée 500 fois. Il est plus élégant de définir un style une fois pour toutes et de le réutiliser mille fois dans la page.

Autre nécessité : raccourcir les noms utilisés.
C'est vrai pour les feuilles de style : un class="d" sera plus économique qu'un class="police_par_defaut_de_la_page_principale". Une lettre est souvent suffisante, éventuellement assortie de chiffres (d01,d02,d55...). Idem pour les images et leur répertoire. Si vous deviez payer un euro par caractère, vous préfèreriez copier 64 fois "images/image01.gif" ou "i/i01.gif" ? (ni l'un ni l'autre diront certains, mais vous avez compris l'idée : compresser ces caractères !). Pour les liens, on se retrouve dans la même situation. Supposons que vous désirez affecter une action à chacune des 64 cases et que seul un numéro varie. Le programmeur insouciant va copier gaillardement "action.php?action=deplacement&id=04" ou 05 ou 06 pour chaque case. La première étape serait, vous l'aurez compris, d'en faire "ac.php?a=d&i=04", mais il y a encore moyen de faire mieux. Le programmeur écologiste va réduire cette ligne à ce qu'elle contient d'essentiel : une action et un chiffre. Il pourra alors créer une fonction javascript qu'il nommera "d" (comme déplacement) ; le lien deviendra "d(04)" et la fonction se chargera de compléter le reste de l'adresse de la page à chaque appel.

Partir sur une bonne base
Vertiges de la base de donnée ! Merveille que ces petites cases qui se remplissent et qui constituent le coeur de votre jeu. Jusqu'à ce qu'elle sature ; qu'elle déborde ; et que la moindre recherche demande plusieurs minutes. Là encore, il y a des réflexes importants pour ne pas gaspiller l'énergie du serveur.

Ne charger que le nécessaire
Une base de données permet de stocker à la fois des données d'usage fréquent et d'usage très rare. Il est important de les dissocier, pour que, dans le déroulement du jeu, seules les informations utiles soient rechargées. Prenons le cas d'un jeu impliquant un personnage. Il sera important de dissocier les données du joueur (identifiant, mot de passe, e-mail) qui ne sont utiles qu'à la connexion, et les données portant sur son personnage (points de vie, points d'action...) qui font l'objet d'un rechargement régulier. L'idéal, c'est de créer autant de bases qu'il y a de types de données dans le jeu : une base pour les objets, une pour les amis du joueur, que sais-je ? N'oubliez pas de faire correspondre chaque table, par exemple avec le même numéro d'identifiant pour le joueur et le personnage correspondant.

La même question doit se poser pour de nombreux détails. Est-il nécessaire de charger la liste complète des joueurs alors qu'il n'y en a que trois à proximité ? Est-il nécessaire de charger la liste complète des objets transportés par le joueur alors qu'on peut les "cacher" dans un sac à dos qui s'ouvre dans ce but ?

Le choix pertinent des données à afficher impose parfois des contorsions techniques (par exemple calculer les coordonnées minimales et maximales de visibilité d'un joueur pour afficher les personnages dont les coordonnées sont comprises dans cet intervalle). Au chapitre de l'arbitrage entre mySQL et PHP, il est parfois difficile de choisir le plus efficace. Un conseil : minimiser la charge PHP en faisant porter les calculs et le tris de données sur la base, qui est optimisée pour. Il faut par exemple proscrire l'usage de "SELECT *" si seul un nom est nécessaire "SELECT nom" sera plus approprié. Il y a d'autres petits trucs pratiques. Si vous souhaitez afficher les 10 premiers enregistrements (par ex. les en-têtes des 10 derniers messages), pas la peine de charger toute la table en mémoire : "SELECT en_tete FROM jeu_messages LIMIT 10" permettra d'économiser beaucoup de chargements puisque la recherche s'arrête dès que le 10e enregistrement est chargé.

Le cas le plus critique est celui des messages : ça enfle, ça gonfle et ça devient très difficile à compacter. Demandez-vous si la liste des messages doit être chargée systématiquement en début de page. Une page dédiée sera peut-être appropriée. Au besoin, il est possible de n'afficher que le dernier message, ou d'avertir le joueur qu'un message est arrivé. Une requête "SELECT id FROM jeu_message LIMIT 1" permet de "détecter" un nouveau message sans surcharger la base.

Compacter les données dans la base
On ne le répètera jamais assez : quand on n'a pas de pétrole, il faut avoir des ID (hum hum...). L'ID (pour index), c'est la clé de l'efficacité dans une base mySQL. Ce n'est pas facile à admettre pour un débutant, mais chaque joueur, chaque message, chaque objet du jeu ne doit être représenté que par un chiffre. Pourquoi ? Pour des raisons de rapidité et de liberté. J'ai moi-même débuté en faisant référence à chaque personnage par son pseudonyme, qui me paraissait suffisamment unique pour être représentatif. Mon code PHP comportait des tableaux de type $points["Roberto L'écolo"]. Oui mais voilà : tout d'abord j'ai eu de nombreux bugs à cause des apostrophes et guillemets, notamment dans les requêtes mysql. D'autre part le temps nécessaire pour une requête est bien plus important lorsqu'il s'agit de rechercher "Roberto L'écolo" que pour rechercher "4055" dans un index. Cela tient à la nature des données stockées dans la base.

Dans un champ "text" (ou "char" ou "blob"), chaque caractère est codé par 1 octet. Notre nom "Roberto L'écolo" occupe donc 15 octets. Supposons qu'il soit défini* par un index compris entre 0 et 65535 : cette valeur se code sur 3 octets. Cela signifie que, chaque fois que vous ferez référence à ce personnage par son ID dans une requête ou un calcul PHP, la quantité de données à calculer sera 5 fois plus petite qu'en utilisant son pseudo. Et croyez-moi, s'il y a 5000 joueurs sur votre partie, votre hébergeur vous remerciera de cet effort. * Cela ne signifie pas que le pseudo doive disparaître, mais il ne doit pas servir d'identifiant pour un joueur

Cette réduction s'applique aux autres données. Pour chaque valeur que vous souhaitez stocker dans la base, choisissez judicieusement le type de données. Comment faire ? Evaluez simplement la valeur MAXIMUM qui sera utile. Sans rentrer dans le détail des types de données mySQL, voici quelques indications générales :

  • ID (identifiant de joueur) : MEDIUMINT UNIQUE -> permet de stocker 65536 joueurs avec un index automatique (en auto-increment, c'est mieux). Et si jamais vous atteignez les 65000 joueurs vous pouvez passer en INT (mySQL convertit sans problème les valeurs lorsqu'on augmente la longueur), ce qui vous autorisera 16 millions de joueurs, mais vous devriez le voir venir.
  • ID (autre identifiant) : s'il y a plusieurs parties dans votre jeu, utilisez par exemple un TINYINT (256 valeurs) pour les repérer. Pour les identifiants de messages, le type INT s'impose. Mieux vaut partir sur une valeur basse, quitte à l'augmenter quand on constate que ça ne va pas suffire.
  • Pseudonyme de personnage : c'est une valeur "lourde" qui pourrait être placée dans une table à part si les accès à la table des persos sont fréquents ; par exemple dans la table des joueurs. Pour la longueur, préférer un champ de type CHAR avec une longueur de 25 (ça évitera les délires de joueurs qui créent des noms de 500 caractères).
  • Valeur alternative oui/non ou 0/1 : SET avec pour valeur '0','1' ou 'oui','non' -> permet de définir les valeurs possibles pour un champ : chaque champ peut alors occuper 1 seul bit de données (un huitième d'octet) dans la base. Par comparaison à un champ texte avec "oui" ou "non" occupe 24 fois plus d'espace.
  • Valeur numérique (points de vie, etc) : à vous de déterminer le maximum acceptable, sans bloquer les joueurs puissants. En général, on optera pour du MEDIUMINT (65536).
  • Date : je n'ai pas de données précises à ce sujet : je préfère stocker les dates sous forme de timestamp (obtenue par un mktime() en PHP) dans un champ INT (ça donne le nombre de secondes écoulées depuis le 1/1/1970, par exemple 1055190185), ce qui permet de manipuler les dates plus simplement qu'avec les fonctions DATE() de mySQL. Il est possible que je me plante sur ce sujet ; je reste ouvert aux suggestions.

    Enfin, si votre système s'y prête, il peut être intéressant de multiplier les références, même si ça impose une gestion très rigoureuse. Dans Tératogénèse, on peut envoyer un même message à 10, 30, 40 personnes à la fois. Au départ, j'ai créé un message pour chaque destinataire. Si l'on compte l'ID de l'émetteur, celle du récepteur, le texte et la date d'émission du message, ma base est très vite devenue astronomique (3 Mo de données pour "seulement" 12000 messages hebdomadaires). Puisque la seule donnée qui changeait pour chaque récepteur était son ID, j'ai décidé de créer UN SEUL message de référence, pour 30 ou 40 messages qui "pointaient" vers ce message en ne comportant qu'un numéro de récepteur. Avec ce système un peu compliqué, j'ai réduit la table à 700ko, ce qui a considérablement accéléré les accès à la base. Ensuite, ça complique un peu la réception et le "nettoyage" des messages trop vieux, mais on s'en sort.

    Optimiser ses requêtes
    MySQL propose un certain nombre de fonctions qui accélèrent grandement son usage : apprenez à les connaître en consultant la documentation ! Supposons par exemple que vous souhaitiez connaître le nombre de joueurs dans votre table. Si vous ne connaissez que la syntaxe SELECT de base, vous pourriez arriver à mysql_query("SELECT * FROM jeu_joueurs") puis à utiliser mysql_affected_rows() pour connaître le nombre d'enregistrements retournés. C'est malin, sauf que vous avez chargé l'intégralité de la table dans la mémoire, et qu'il existe une fonction spécialement optimisée de mySQL pour connaître le nombre d'enregistrements : "SELECT COUNT(*) FROM jeu_joueurs". Au lieu de charger 800 lignes de données, vous ne chargez qu'un simple chiffre (récupérable dans un tableau par un mysql_fetch_row(), mais ce n'est pas le sujet), d'où une économie monumentale. Je vous invite, dès qu'un problème se pose, à vérifier si la doc mySQL ne propose pas LA solution.

    Dans le même ordre d'idées, évitez à tout prix les requêtes mySQL successives (par exemple dans une boucle for ou foreach portant sur chaque joueur). Chaque accès à une base demande quelques millisecondes précieuses, même si la requête est rapide. Il vaut mieux donc trouver la bonne clause WHERE qui englobe tous les joueurs concernés que de répéter la même action pour chacun, mais j'y reviendrai plus bas.

    Recycler son code
    Tout comme le tri des déchets ménagers, l'écologie du code est une astreinte pour le concepteur, mais un soulagement pour l'environnement (des joueurs). Il faut donc s'organiser et avoir une vision assez claire du "strict minimum" et du superflu. Et il y a plein d'endroits où le code, mine de rien, pourrait être recyclé, réutilisé, compacté. Quelques exemples :

    Diviser pour mieux ranger
    Les frames, c'est une chienlit. Pour la gestion des liens, pour la mémoire des sessions, c'est une torture. Pour répondre à ce problème, PHP permet de gérer l'affichage conditionnel des éléments d'une page. Par exemple, une structure IF ($inv== 1) print $inventaire autorise l'affichage des seuls éléments dont on a besoin dans la page. Il est commode de tout réunir dans une seule page, d'empiler les lignes de code dans un fichier bien dense où la logique apparaît nettement. Pourtant, dans bien des cas, cela entraîne des gâchis. La solution s'appelle include(). En subdivisant votre code en petites pages dédiées à un affichage spécifique, le code ne sera chargé par le serveur que si la condition du IF est remplie. Vous conservez la flexibilité des frames (on n'affiche que le nécessaire) tout en évitant leurs inconvénients.

    Vive les fonctions !
    La fonction, c'est le Graal du compactage. C'est vrai pour les fonctions spécifiques du code PHP : avant de taper dix lignes de code pour chercher les valeurs uniques dans un tableau, mieux vaut consulter la doc et constater qu'il existe array_unique(), spécialement dédiée à cet effet. C'est vrai également des fonctions utilisateur, que vous créer pour automatiser une tâche répétitive.

    Quand faut-il créer une fonction ? C'est simple, dès que vous utilisez une même action deux fois de suite, avec quelques paramètres qui changent. Il faut être attentif à tous les endroits où l'on peut réduire le nombre de variables différentes et créer une fonction. Il peut être très économique de créer des fonctions pour les requêtes SQL. Ainsi, la fonction message (de qui, à qui, texte) appellera la requête mySQL appropriée, en calculant la date et tout ce qu'il faut.

    Au delà de l'économie, les fonctions assurent une cohérence au code (les messages seront toujours écrits de la même manière) et simplifient la mise à jour (si l'on ajoute une colonne à notre table de messages, il suffira de modifier la requête mySQL dans la fonction, et pas à chaque appel).

    Vérifier AVANT de calculer
    Il y a des structures a priori innocentes, qui, prises dans le mauvais sens, peuvent mettre à genoux un serveur. Supposons que vous vouliez vérifier pour chacun des 500 joueurs s'il a joué depuis 24h et, le cas échéant, afficher son id. Il y a deux manières de procéder :

    Le programmeur bête et obéissant réunit la liste des joueurs (il l'a déjà obtenue dans un tableau $joueur[$id]). Il lance donc un foreach() qui va vérifier pour chaque joueur la date entrée dans la base et la comparer à la date actuelle. LE SERVEUR MEURT.

    Le programmeur écologiste, plus fûté, a compris comment rationaliser cette situation. Il calcule la date moins 24h ($hier), puis exécute une simple requête mysql_query ("SELECT id FROM jeu_persos WHERE date>$hier"). Il n'a plus qu'a lancer l'affichage pour chacun des résultats. LE SERVEUR RIT.

    Dans le premier exemple, c'est la répétition de requêtes minimes qui a surchargé le serveur : cela s'explique par le fait que toute requête, même portant sur une seule entrée, suppose un balayage d'une bonne partie de la table pour rapporter une seule info. Il vaut mieux récupérer beaucoup d'infos une seule fois qu'une seule info 500 fois. Cette logique s'applique dès qu'une opération répétitive apparaît dans le code. Tous les while, for, foreach doivent faire l'objet d'une enquête attentive : qu'est-ce qu'on peut sortir de la boucle ? Qu'est-ce qui peut être calculé UNE SEULE FOIS au lieu de 500 fois ?

    Recycler ses emballages de variables
    A priori anecdotique, le nombre de variables utilisées dans le code peut augmenter l'occupation de mémoire et ralentir d'autant le serveur. Ce nombre devrait être réduit en réutilisant les "déchets". C'est surtout valable avec les appels $sql = mysql_query(), qui renvoient dans la variable $sql une ressource de taille souvent importante. Comme cette ressource est passée dans un mysql_fetch_array() ou mysql_fetch_row() pour être utilisable, elle devient inutile et encombre donc la mémoire pour rien. Il y a deux solutions : si le résultat de la requête ne fait qu'une ligne (par exemple le chargement des valeurs du joueur principal), vous pouvez réutiliser la même variable pour en faire un tableau * : $sql = mysql_fetch_array($sql). Si le résultat de la requête comporte plusieurs lignes, il ne peut pas être réutilisé directement puisqu'il faut créer un tableau et repasser la valeur pour chaque ligne. La solution : réutiliser la variable $sql dans les prochaines requêtes SQL. Cette variable est donc à usage multiple ; on garde son nom mais on efface son contenu pour le remplacer par un nouveau. * Ça peut paraître illogique que a=truc(a) mais rappelez vous que le signe "=" ne représente pas une égalité mais une affectation : dès que la partie droite est calculée, la variable est créée avec la valeur calculée.

    Conclusion
    Il va de soi que cette présentation n'est pas exhaustive. Ecolo-développeur en herbe, je découvre chaque jour de nouvelles manières de simplifier, d'alléger le jeu et d'économiser les ressources limitées du serveur. Il y a de vastes possibilités dans les fonctions mySQL (fonctions de recherches croisées sur les tableaux, etc...) qui permettent des choses étonnantes dont je n'entrevois l'intérêt qu'après deux ans de pratique quasi quotidienne du PHP. Mais je crois avoir abordé l'élément essentiel qui s'impose dès que l'on veut alléger un jeu : rechercher systématiquement, frénétiquement, les possibilités de compacter son jeu. Je l'ai dit, et je persiste : vos joueurs vous en seront reconnaissants... 

    Copyright TourDeJeu, portail des jeux en ligne alternatifs, site indépendant