Votre jeu est truffé de boucles de requêtes SQL? Il est lent et vous ne voulez pas dépenser plus pour un plus gros hébergement? Ou avez-vous déjà dépensé cet argent et cela n’a rien amélioré? Alors plongez dans cet article qui vous montrera comment tirer le meilleur parti de +.

Le problème des tutos “simplistes”

PHP (et son ami MySQL) sont de très bons outils pour développer un site web: rapides à installer et à prendre en main, avec une forte communauté et des années d’existence ayant permis de régler la plupart des problèmes et de fournir des tutoriaux accessibles à tous, même aux plus débutants. Mais cela se paie parfois, quand ces tutos sont eux-mêmes rédigés par des débutants, ou proposent des méthodes dont l’ laisse à désirer. Les novices qui suivent ces conseils sont alors mis sur de mauvais rails. Essayons de voir comment revenir dans un chemin plus fiable, efficace et pérenne.

La boucle de requêtes PHP

Un créateur de jeu web qui a suivi des tutos de ce genre finit souvent avec ce genre de code:

// Ne faites pas ça!
$playerTanks = $pdo->query('SELECT * FROM tanks WHERE id_player = ' . $idPlayer)->fetchAll();
foreach ($playerTanks as $playerTank) {
  // Tirer sur l'ennemi
  if ($playerTank['ennemy']) {
    // Dégats subis
    $hit = mt_rand($playerTank['minDamage'], $playerTank['maxDamage']);
    $ennemy = $pdo->query('SELECT * FROM tanks WHERE id_tank = ' . $playerTank['ennemy'])->fetch();
    if ($ennemy['health'] <= $hit) {
      // L'ennemi est mort
      $pdo->query('DELETE FROM tanks WHERE id = ' . $ennemy['id']);
      // Notre tank n'a plus de cible
      $pdo->query('UPDATE tanks SET ennemy = NULL WHERE id = ' . $playerTank['id']);
    } else {
      // L'ennemi est blessé
      $pdo->query('UPDATE tanks SET health = ' . ($ennemy['health'] - $hit) . ' WHERE id = ' . $ennemy['id']);
    }
  }
  // Changer de cible (on ne cible pas nos propres tanks!)
  $target = $pdo->query('SELECT * FROM tanks WHERE id_player != ' . $idPlayer . ' ORDER BY RAND()')->fetchAll();
  // Sauver la nouvelle cible (s'il y en a)
  if ($target) {
    $pdo->query('UPDATE tanks SET ennemy = ' . $target[0]['id'] . ' WHERE id = ' . $playerTank['id']);
  } else {
    $pdo->query('UPDATE tanks SET ennemy = NULL WHERE id = ' . $playerTank['id']);
  }
}
Un code récurrent chez les débutants

Or, ce genre de code finit fatalement par péter à la figure de son créateur (et si cela n’arrive pas, alors cela signifie que le jeu n’est juste pas du tout joué). Voyons comment l’améliorer en évitant chacun des écueils qu’il contient.

Les requêtes préparées

D’abord, votre code est vulnérable aux injections SQL: si une variable contient une donnée foireuse, alors vous risquez de vous faire pirater votre BDD. La règle est simple: utilisez toujours des requêtes préparées. De cette manière, la requête passera dans un “pipe” jusqu’au serveur SQL, et les données qu’elle doit utiliser passeront dans un autre: aucun risque que les deux se mélangent.

// Fonction générique à centraliser dans un seul fichier
// et à charger par require_once
function prepareAndExecute(PDO $pdo, string $query, array $params): PDOStatement {
  $st = $pdo->prepare($query);
  $st->execute($params);
  return $st;
}


$playerTanks = prepareAndExecute($pdo, 'SELECT * FROM tanks WHERE id_player = ?', array($idPlayer))->fetchAll();
foreach ($playerTanks as $playerTank) {
  // Tirer sur l'ennemi
  if ($playerTank['ennemy']) {
    // Dégats subis
    $hit = mt_rand($playerTank['minDamage'], $playerTank['maxDamage']);
    $ennemy = prepareAndExecute($pdo, 'SELECT * FROM tanks WHERE id_tank = ?', array($playerTank['ennemy']))->fetch();
    if ($ennemy['health'] <= $hit) {
      // L'ennemi est mort
      prepareAndExecute($pdo, 'DELETE FROM tanks WHERE id = ?', array($ennemy['id']));
      // Notre tank n'a plus de cible
      prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = NULL WHERE id = ', array($playerTank['id']));
    } else {
      // L'ennemi est blessé
      prepareAndExecute($pdo, 'UPDATE tanks SET health = ? WHERE id = ?', array($ennemy['health'] - $hit, $ennemy['id']));
    }
  }
  // Changer de cible (on ne cible pas nos propres tanks!)
  $target = prepareAndExecute($pdo, 'SELECT * FROM tanks WHERE id_player != ? ORDER BY RAND()', array($idPlayer))->fetchAll();
  // Sauver la nouvelle cible (s'il y en a)
  if ($target) {
    prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = ? WHERE id = ?', array($target[0]['id'], $playerTank['id']));
  } else {
    prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = NULL WHERE id = ?', array($playerTank['id']));
  }
}
Requêtes préparées

Transaction

Maintenant, supposez que plusieurs joueurs jouent en même temps, ou qu’un joueur recharge la page: vous ne voulez pas que les données d’une page se mélange avec l’autre?! Qu’arriverait-il si, par exemple, deux joueurs chargent la page, et tirent sur le même ennemi? Le 1er joueur tue le tank ennemi, et le 2nd joueur? Il tire sur du vide! Pour éviter cela, il faut passer par les transactions. Elles permettent d’assurer que les données lues par la 1ere requête dans la transaction ne seront pas altérées par d’autres avant la fin de la transaction. De plus, en cas d’erreur, toute la transaction sera annulée et vous n’aurez pas des “bouts” de BDD modifiés et pas d’autres.

// Fonction générique à centraliser dans un seul fichier
// et à charger par require_once
function prepareAndExecute(PDO $pdo, string $query, array $params): PDOStatement {
  $st = $pdo->prepare($query);
  $st->execute($params);
  return $st;
}

try {
  $pdo->transactionBegin();
  $playerTanks = prepareAndExecute($pdo, 'SELECT * FROM tanks WHERE id_player = ?', array($idPlayer))->fetchAll();
  foreach ($playerTanks as $playerTank) {
    // Tirer sur l'ennemi
    if ($playerTank['ennemy']) {
      // Dégats subis
      $hit = mt_rand($playerTank['minDamage'], $playerTank['maxDamage']);
      $ennemy = prepareAndExecute($pdo, 'SELECT * FROM tanks WHERE id_tank = ?', array($playerTank['ennemy']))->fetch();
      if ($ennemy['health'] <= $hit) {
        // L'ennemi est mort
        prepareAndExecute($pdo, 'DELETE FROM tanks WHERE id = ?', array($ennemy['id']));
        // Notre tank n'a plus de cible
        prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = NULL WHERE id = ', array($playerTank['id']));
      } else {
        // L'ennemi est blessé
        prepareAndExecute($pdo, 'UPDATE tanks SET health = ? WHERE id = ?', array($ennemy['health'] - $hit, $ennemy['id']));
      }
    }
    // Changer de cible (on ne cible pas nos propres tanks!)
    $target = prepareAndExecute($pdo, 'SELECT * FROM tanks WHERE id_player != ? ORDER BY RAND()', array($idPlayer))->fetchAll();
    // Sauver la nouvelle cible (s'il y en a)
    if ($target) {
      prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = ? WHERE id = ?', array($target[0]['id'], $playerTank['id']));
    } else {
      prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = NULL WHERE id = ?', array($playerTank['id']));
    }
  }
  $pdo->commit();
} catch (Throwable $ex) {
  $pdo->rollback();
  // Logger l'erreur
}
Transaction!

Les tables en MyISAM

Mais la transaction a un inconvénient: tant qu’elle n’est pas terminée, aucun autre code ne pourra accéder aux lignes qu’elle modifie (verrouillage). Or, la plupart des débutants ne font pas attention à une chose: le moteur de stockage de leurs tables en BDD. Il en existe deux majeurs: MyISAM, et InnoDB. Le premier est ancien, le second récent. Le premier est lent, le second véloce. Le premier ne gère pas les foreign key, le second les gère. Et surtout, le premier verrouille toute la table alors que le second ne verrouille que les lignes modifiées.

Par exemple, si 10 joueurs accèdent à la page avec des tables en MyISAM, alors le 1er joueur verrouillera les tables, exécutera les actions du script PHP, puis déverrouillera les tables et le 2nd joueur sera pris en charge. Le 10e joueur aura donc attendu 10x plus longtemps que la page s’exécute. En InnoDB, les joueurs seront servis quasiment en même temps.

Ne récupérez que ce qui sert

MySQL est un serveur, qui sert votre PHP tout comme votre serveur Apache sert le navigateur web. Il y a donc un trafic de données entre les deux. Et plus ce trafic est gros, plus le serveur MySQL sera lent à répondre. Donc, vous devez éviter de transférer des données inutiles. Attention: certains, par abus, peuvent vous dire de ne jamais faire de SELECT *, mais c’est faux! Ce que vous devez faire, c’est ne récupérer que les colonnes qui vous sont utiles. Parfois, vous aurez besoin de toutes (auquel cas, SELECT *, c’est pratique), parfois non.

// Je vous épargne une partie du code déjà vu

$pdo->transactionBegin();
$playerTanks = prepareAndExecute($pdo, 'SELECT id, ennemy FROM tanks WHERE id_player = ?', array($idPlayer))->fetchAll();
foreach ($playerTanks as $playerTank) {
  // Tirer sur l'ennemi
  if ($playerTank['ennemy']) {
    // Dégats subis
    $hit = mt_rand($playerTank['minDamage'], $playerTank['maxDamage']);
    $ennemy = prepareAndExecute($pdo, 'SELECT health FROM tanks WHERE id_tank = ?', array($playerTank['ennemy']))->fetch();
    if ($ennemy['health'] <= $hit) {
      // L'ennemi est mort
      prepareAndExecute($pdo, 'DELETE FROM tanks WHERE id = ?', array($playerTank['ennemy']));
      // Notre tank n'a plus de cible
      prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = NULL WHERE id = ', array($playerTank['id']));
    } else {
      // L'ennemi est blessé
      prepareAndExecute($pdo, 'UPDATE tanks SET health = ? WHERE id = ?', array($ennemy['health'] - $hit, $playerTank['ennemy']));
    }
  }
  // Changer de cible (on ne cible pas nos propres tanks!)
  $target = prepareAndExecute($pdo, 'SELECT id FROM tanks WHERE id_player != ? ORDER BY RAND()', array($idPlayer))->fetchAll();
  // Sauver la nouvelle cible (s'il y en a)
  if ($target) {
    prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = ? WHERE id = ?', array($target[0]['id'], $playerTank['id']));
  } else {
    prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = NULL WHERE id = ?', array($playerTank['id']));
  }
}
$pdo->commit();
Ne récupérez que ce qui sert

Groupez les queries

Chaque requête au serveur MySQL est plus lente qu’une absence de requête. Et souvent, deux requêtes sont donc plus lentes qu’une seule. Il est donc conseillé de grouper vos queries, et de ne pas en faire d’inutiles. Dans notre exemple, il ne sert à rien de SET la colonne ennemy à NULL quand le tank du joueur a tué son ennemi. En effet, cette valeur sera de nouveau écrasée en fin de boucle foreach. De même, la requête de changement de cible peut être regroupée en une seule query, et les requêtes de tir sur l’ennemi peuvent aussi être réduites (d’autant plus utile pour la suite).

// Je vous épargne une partie du code déjà vu

$pdo->transactionBegin();
$playerTanks = prepareAndExecute($pdo, 'SELECT id, ennemy FROM tanks WHERE id_player = ?', array($idPlayer))->fetchAll();
foreach ($playerTanks as $playerTank) {
  // Tirer sur l'ennemi
  if ($playerTank['ennemy']) {
    // Dégats subis
    $hit = mt_rand($playerTank['minDamage'], $playerTank['maxDamage']);
    // L'ennemi est blessé
    prepareAndExecute($pdo, 'UPDATE tanks SET health = GREATEST(0, health - ?) WHERE id = ?', array($hit, $playerTank['ennemy']));
    // L'ennemi est mort si sa vie est à 0
    // Si elle ne l'est pas, le "WHERE" ne trouvera rien, et rien ne sera supprimé
    prepareAndExecute($pdo, 'DELETE FROM tanks WHERE id = ? AND health = 0', array($playerTank['ennemy']));
  }
  // Changer de cible (on ne cible pas nos propres tanks!)
  prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = (SELECT id FROM tanks WHERE id_player != ? ORDER BY RAND() LIMIT 1) WHERE id = ?', array($idPlayer, $playerTank['id']));
}
$pdo->commit();
Groupez les queries du foreach

Utilisez les fonctions SQL

De nombreuses fonctions de calculs sur les données existent dans MySQL. Profitez-en pour les utiliser directement: cela vous évitera d’avoir du code PHP pour cela.

// Je vous épargne une partie du code déjà vu

$pdo->transactionBegin();
$playerTanks = prepareAndExecute($pdo, 'SELECT id, ennemy FROM tanks WHERE id_player = ?', array($idPlayer))->fetchAll();
foreach ($playerTanks as $playerTank) {
  // Tirer sur l'ennemi
  if ($playerTank['ennemy']) {
    // L'ennemi est blessé
    prepareAndExecute(
      $pdo,
      'UPDATE tanks SET health = GREATEST(0, health - (RAND() * ? + ?)) WHERE id = ?',
      array($playerTank['maxDamage'] - $playerTank['minDamage'], $playerTank['minDamage'], $playerTank['ennemy']));
    // L'ennemi est mort si sa vie est à 0
    prepareAndExecute($pdo, 'DELETE FROM tanks WHERE id = ? AND health = 0', array($playerTank['ennemy']));
  }
  // Changer de cible (on ne cible pas nos propres tanks!)
  prepareAndExecute($pdo, 'UPDATE tanks SET ennemy = (SELECT id FROM tanks WHERE id_player != ? ORDER BY RAND() LIMIT 1) WHERE id = ?', array($idPlayer, $playerTank['id']));
}
$pdo->commit();
Utilisez les fonctions de MySQL

Ayez une approche ensembliste

MySQL (et les serveurs SQLs en général) servent à traiter des ensembles de données. Ici, vous ne traitez pas un ensemble de données, mais ligne par ligne… D’où des performances calamiteuses! Pour éviter cela, il vous faut avoir une approche globale de l’ensemble de vos données. C’est un point assez difficile à appréhender au début, mais une fois le truc compris, vous le ferez naturellement.

// Je vous épargne une partie du code déjà vu

$pdo->transactionBegin();
// Tous les ennemis sont blesses d'un coup
prepareAndExecute(
  $pdo,
  'UPDATE tanks AS ennemies'
    . ' INNER JOIN tanks AS friends ON friends.ennemy = ennemies.id'
    . ' SET ennemies.health = GREATEST(0, health - (RAND() * (friends.maxDamage - friends.minDamage) + friends.minDamage))'
    . ' WHERE friends.id_player = ?',
  array($idPlayer));

// Tous les ennemis sans vie meurent d'un coup
prepareAndExecute($pdo, 'DELETE FROM tanks WHERE health = 0');

// Tous les tanks changent de cible d'un coup
prepareAndExecute(
  $pdo,
  'UPDATE tanks AS friends '
    . ' SET ennemy = (SELECT id FROM tanks AS ennemies WHERE ennemies.id_player != friends.id_player ORDER BY RAND() LIMIT 1)'
    . ' WHERE friends.id_player = ?',
  array($idPlayer));

$pdo->commit();
Ayez une approche ensembliste

Attention à bien créer vos indexes: ici, vous aurez besoin d’un index sur tanks.id_player et probablement sur tanks.health (pour la mort des tanks).

Optimisez ce qui reste

Vous avez déjà fait un sacré chemin! Vous pouvez vous arrêter là. Avec le temps, vous trouverez parfois de petites astuces pour optimiser encore certaines des rares queries qu’il vous reste (par exemple, pour éviter le ORDER BY RAND()). De même, vous pouvez encapsuler ces queries dans une procédure stockée, pour n’avoir qu’un prepareAndExecute($pdo, 'CALL my_procedure(?)', array($idPlayer)) à faire depuis PHP (ou Java, ou autre) et gérer plus finement certaines queries.

// Faites cela
// Fonction générique à centraliser dans un seul fichier
// et à charger par require_once
function prepareAndExecute(PDO $pdo, string $query, array $params): PDOStatement {
  $st = $pdo->prepare($query);
  $st->execute($params);
  return $st;
}

try {
  $pdo->transactionBegin();
  // Les ennemis sont blesses
  prepareAndExecute(
    $pdo,
    'UPDATE tanks AS ennemies'
      . ' INNER JOIN tanks AS friends ON friends.ennemy = ennemies.id'
      . ' SET ennemies.health = GREATEST(0, health - (RAND() * (friends.maxDamage - friends.minDamage) + friends.minDamage))',
    array($idPlayer));
 
  // Les ennemis sans vie meurent
  prepareAndExecute($pdo, 'DELETE FROM tanks WHERE health = 0');
 
  // Changer de cible (on ne cible pas nos propres tanks!)
  prepareAndExecute(
    $pdo,
    'UPDATE tanks AS friends '
      . ' SET ennemy = (SELECT id FROM tanks AS ennemies WHERE ennemies.id_player != friends.id_player ORDER BY RAND() LIMIT 1)'
      . ' WHERE friends.id_player = ?', array($idPlayer));
  $pdo->commit();
} catch (Throwable $ex) {
  $pdo->rollback();
  // Logger l'erreur
}
Code final!