Playlab42 le laboratoire
GuidesCréer un Client de Jeu

Créer un Client de Jeu#

Ce guide vous accompagne dans la création d'une interface utilisateur pour un jeu Playlab42.

Objectif#

Créer un client de jeu qui :

  • Affiche l'état du jeu de manière visuelle
  • Gère les interactions utilisateur
  • Communique avec le moteur de jeu
  • Intègre les bots pour le mode solo

Prérequis#

Structure d'un client#

games/mon-jeu/
├── index.html     # Client (interface)
├── engine.js      # Moteur (logique)
├── bots/          # Bots disponibles
│   └── random.js
└── game.json      # Manifest

Étapes#

1. Structure HTML de base#

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Mon Jeu - Playlab42</title>
  <link rel="stylesheet" href="../../lib/theme.css">
  <style>
    /* Vos styles - utilisez les variables CSS */
    body {
      font-family: var(--font-family);
      background: var(--color-bg);
      color: var(--color-text);
      transition: background-color var(--transition-normal);
    }
  </style>
  <script type="module">
    import { initTheme } from '../../lib/theme.js';
    initTheme();
  </script>
</head>
<body>
  <!-- Écran de configuration -->
  <div class="config-screen" id="config-screen">
    <h1>Mon Jeu</h1>
    <!-- Options -->
    <button id="btn-start">Jouer</button>
  </div>

  <!-- Écran de jeu -->
  <div class="game-screen" id="game-screen">
    <div class="status" id="status"></div>
    <div class="board" id="board"></div>
    <div class="controls">
      <button id="btn-restart">Rejouer</button>
      <button id="btn-menu">Menu</button>
    </div>
  </div>

  <script type="module">
    import { $, $$, on } from '../../lib/dom.js';
    // Code du client
  </script>
</body>
</html>

2. Utiliser le système de thèmes#

Importez lib/theme.css pour avoir accès aux variables de design. Les thèmes dark/light sont gérés automatiquement.

/* Variables principales disponibles */
--color-bg              /* Fond principal */
--color-bg-secondary    /* Fond secondaire */
--color-bg-card         /* Fond des cartes */
--color-text            /* Texte principal */
--color-accent          /* Couleur d'accent */
--color-success         /* Succès (victoire) */
--color-error           /* Erreur (défaite) */
--color-player-x        /* Couleur joueur X */
--color-player-o        /* Couleur joueur O */
--color-win             /* Couleur ligne gagnante */

3. Importer les dépendances#

import { $, $$, on } from '../../lib/dom.js';
import { MonJeuEngine } from './engine.js';
import { SeededRandom } from '../../lib/seeded-random.js';
import { RandomBot } from './bots/random.js';

Cycle de vie du SDK#

Pour les clients qui utilisent GameKit, appeler GameKit.init('mon-jeu') une fois par session. dispose() retire les écouteurs internes, libère les assets et autorise une nouvelle initialisation. Le hook onGameDispose peut encore sauvegarder la progression avant cette libération.

import GameKit from '../../lib/gamekit.js';
import { initTheme, onThemeChange } from '../../lib/theme.js';

const stopTheme = initTheme();
const unsubscribe = onThemeChange(() => render());
GameKit.init('mon-jeu');

window.onGameDispose = () => {
  if (!GameKit.saveProgress(state)) {
    console.error('La progression n’a pas pu être sauvegardée.');
  }
  unsubscribe();
  stopTheme();
};

L'initialisation du thème est idempotente ; son nettoyage ne retire pas les abonnements créés séparément avec onThemeChange. syncTheme() applique une préférence déjà importée sans écrire dans le stockage.

Les chemins relatifs d'assets sont résolus depuis le dossier du jeu, en conservant le préfixe de déploiement du site. Les URLs absolues, data: et blob: restent explicites. Une promesse de chargement encore en cours rejette lors du nettoyage : le client doit traiter cette annulation.

Les types du protocole portail/jeu sont disponibles dans lib/types/ pour les consommateurs TypeScript, sans imposer TypeScript aux clients HTML. Les helpers de scores et progression conservent leur API publique ; une erreur de lecture est signalée et ne supprime plus les données corrompues. Voir Données locales pour la sauvegarde et la restauration.

Jeux temps réel : horloge, pause et entrées#

La boucle d'animation appartient au client ; le moteur ne lit ni Date.now(), ni performance.now(), ni un timer du navigateur. Transmettre la durée simulée par une action propre au jeu, par exemple { type: 'tick', delta: 100 }, et conserver dans l'état JSON les délais qui influencent les règles. Cela permet de rejouer exactement une partie et de reprendre après sérialisation.

Définir l'unité et les bornes du delta, le comportement d'un tick qui traverse plusieurs événements et leur priorité en cas de simultanéité. Tester les limites exactes d'un chronomètre ou d'un délai de verrouillage ; ne pas compter les frames comme si tous les écrans avaient la même fréquence.

En pause, arrêter la consommation de temps simulé et vider les commandes maintenues. À la reprise, réinitialiser la référence temporelle de la boucle pour ne pas appliquer la durée passée dans un autre onglet. GameKit appelle onGameResume au retour de visibilité : le client doit choisir délibérément entre reprise automatique et reprise manuelle, notamment pour un jeu où une pièce peut tomber. Une interruption longue de rendu mérite elle aussi une politique explicite : pause, rattrapage borné ou simulation complète, selon les règles du jeu.

Libérer les touches et pointeurs lors de blur, pointercancel, perte de capture, pause et démontage. Ne pas dépendre de la répétition clavier du système pour cadencer une commande de jeu. À onGameDispose, annuler la frame planifiée, retirer les écouteurs du client et libérer les ressources audio : le nettoyage interne du SDK ne nettoie pas automatiquement la boucle propre au jeu.

Séparer les records par objectif. Un score maximal et un meilleur temps minimal ne peuvent pas utiliser le même tri décroissant. Garder un petit format versionné propre au jeu via saveProgress, traiter sa valeur de retour et rendre visibles les échecs de sauvegarde. Ne pas effacer un stockage corrompu ou futur pour forcer une nouvelle écriture ; consulter le guide Données locales.

Ajouter les scénarios navigateur dans e2e/ avec les fixtures existantes : action réelle, maintien puis relâchement, perte de focus, temps figé en pause, reprise volontaire si choisie, fin de partie, utilisation à petite largeur et arrêt lors du message unload. Les tests d'un moteur pur ne prouvent pas ces contrats du client, et une vérification temporaire non versionnée ne les protège pas dans la CI.

4. Gérer l'état du client#

// État du client
let engine = new MonJeuEngine();
let state = null;
let rng = null;
let bot = null;
let isVsBot = true;
let humanId = 'human';
let opponentId = 'opponent';

// Éléments DOM
const configScreen = document.getElementById('config-screen');
const gameScreen = document.getElementById('game-screen');
const statusEl = document.getElementById('status');
const boardEl = document.getElementById('board');

5. Démarrer une partie#

function startGame() {
  // Créer le générateur aléatoire
  rng = new SeededRandom(Date.now());

  // Créer le bot si nécessaire
  const opponentType = document.getElementById('opponent').value;
  isVsBot = opponentType !== 'human';
  bot = isVsBot ? createBot(opponentType) : null;

  // Initialiser le moteur
  state = engine.init({
    seed: rng.getState(),
    playerIds: [humanId, opponentId],
  });

  // Notifier le bot
  if (bot && bot.onGameStart) {
    bot.onGameStart(opponentId, state);
  }

  // Afficher l'écran de jeu
  configScreen.classList.add('hidden');
  gameScreen.classList.add('active');

  // Premier rendu
  render();

  // Si le bot commence
  if (state.currentPlayerId === opponentId && isVsBot) {
    setTimeout(playBot, 500);
  }
}

6. Afficher l'état (render)#

function render() {
  // Mettre à jour l'interface avec l'état actuel
  renderBoard();
  renderStatus();
}

function renderBoard() {
  // Exemple pour une grille
  const cells = document.querySelectorAll('.cell');
  cells.forEach((cell, i) => {
    const value = state.board[i];
    cell.textContent = value || '';
    cell.className = 'cell';
    if (value) cell.classList.add('taken');
    if (state.gameOver) cell.classList.add('game-over');
  });
}

function renderStatus() {
  if (state.gameOver) {
    if (state.winners?.includes(humanId)) {
      statusEl.textContent = 'Vous avez gagné !';
    } else if (state.winners) {
      statusEl.textContent = 'Vous avez perdu';
    } else {
      statusEl.textContent = 'Match nul';
    }
  } else {
    const isMyTurn = state.currentPlayerId === humanId;
    statusEl.textContent = isMyTurn ? 'À vous' : 'Tour adverse...';
  }
}

7. Gérer les actions du joueur#

function handlePlayerAction(actionData) {
  // Vérifier que c'est notre tour
  if (state.gameOver) return;
  if (isVsBot && state.currentPlayerId !== humanId) return;

  const currentPlayer = state.currentPlayerId;
  const action = { type: 'play', ...actionData };

  // Valider l'action
  if (!engine.isValidAction(state, action, currentPlayer)) {
    console.warn('Action invalide');
    return;
  }

  // Appliquer l'action
  state = engine.applyAction(state, action, currentPlayer);
  render();

  // Si partie terminée
  if (state.gameOver) {
    onGameEnd();
    return;
  }

  // Tour du bot
  if (isVsBot && state.currentPlayerId === opponentId) {
    setTimeout(playBot, 500);
  }
}

8. Faire jouer le bot#

function playBot() {
  if (state.gameOver) return;
  if (state.currentPlayerId !== opponentId) return;

  // Obtenir la vue du bot
  const view = engine.getPlayerView(state, opponentId);
  const validActions = engine.getValidActions(state, opponentId);

  if (validActions.length === 0) return;

  // Demander au bot de choisir
  const action = bot.chooseAction(view, validActions, rng);

  // Appliquer l'action
  state = engine.applyAction(state, action, opponentId);
  render();

  if (state.gameOver) {
    onGameEnd();
  }
}

9. Event Listeners#

// Boutons
document.getElementById('btn-start').addEventListener('click', startGame);
document.getElementById('btn-restart').addEventListener('click', restart);
document.getElementById('btn-menu').addEventListener('click', backToMenu);

// Interactions plateau
boardEl.addEventListener('click', (e) => {
  const cell = e.target.closest('.cell');
  if (!cell) return;
  const index = parseInt(cell.dataset.index);
  handlePlayerAction({ position: index });
});

// Raccourcis clavier
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') backToMenu();
  if (e.key === 'r') restart();
});

Exemple complet : Deviner le nombre#

Un jeu simple où il faut deviner un nombre entre 1 et 100.

games/guess-number/engine.js#

export class GuessNumberEngine {
  init(config) {
    // Utiliser le seed pour générer le nombre secret
    const secretNumber = (config.seed % 100) + 1;

    return {
      secretNumber,
      guesses: [],
      maxGuesses: 7,
      currentPlayerId: config.playerIds[0],
      gameOver: false,
      winners: null,
      turn: 1,
      rngState: config.seed,
      playerIds: config.playerIds,
    };
  }

  applyAction(state, action, playerId) {
    if (!this.isValidAction(state, action, playerId)) {
      throw new Error('Invalid action');
    }

    const guess = action.number;
    const newGuesses = [...state.guesses, {
      number: guess,
      hint: guess < state.secretNumber ? 'higher'
          : guess > state.secretNumber ? 'lower'
          : 'correct',
    }];

    const newState = {
      ...state,
      guesses: newGuesses,
      turn: state.turn + 1,
    };

    // Gagné ?
    if (guess === state.secretNumber) {
      newState.gameOver = true;
      newState.winners = [playerId];
    }
    // Perdu ?
    else if (newGuesses.length >= state.maxGuesses) {
      newState.gameOver = true;
      newState.winners = null;
    }

    return newState;
  }

  isValidAction(state, action, playerId) {
    return (
      !state.gameOver &&
      state.currentPlayerId === playerId &&
      action.type === 'guess' &&
      action.number >= 1 &&
      action.number <= 100
    );
  }

  getValidActions(state, playerId) {
    if (state.gameOver || state.currentPlayerId !== playerId) {
      return [];
    }
    // 100 actions possibles (1-100)
    return Array.from({ length: 100 }, (_, i) => ({
      type: 'guess',
      number: i + 1,
    }));
  }

  getPlayerView(state, _playerId) {
    // Cacher le nombre secret
    return {
      ...state,
      secretNumber: undefined,
    };
  }

  isGameOver(state) {
    return state.gameOver;
  }

  getWinners(state) {
    return state.winners;
  }

  getCurrentPlayer(state) {
    return state.currentPlayerId;
  }
}

games/guess-number/index.html#

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Deviner le nombre - Playlab42</title>
  <style>
    :root {
      --bg: #1a1a2e;
      --bg-secondary: #16213e;
      --text: #eee;
      --text-muted: #888;
      --accent: #e94560;
      --accent-hover: #ff6b6b;
      --success: #4ade80;
      --error: #ef4444;
    }

    * { margin: 0; padding: 0; box-sizing: border-box; }

    body {
      font-family: system-ui, sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      padding: 1rem;
    }

    .container {
      background: var(--bg-secondary);
      padding: 2rem;
      border-radius: 12px;
      max-width: 400px;
      width: 100%;
      text-align: center;
    }

    h1 { margin-bottom: 1rem; }

    .info {
      color: var(--text-muted);
      margin-bottom: 1.5rem;
    }

    .input-group {
      display: flex;
      gap: 0.5rem;
      margin-bottom: 1rem;
    }

    input[type="number"] {
      flex: 1;
      padding: 0.75rem;
      background: var(--bg);
      border: 1px solid #333;
      border-radius: 8px;
      color: var(--text);
      font-size: 1.25rem;
      text-align: center;
    }

    input:focus {
      outline: none;
      border-color: var(--accent);
    }

    .btn {
      padding: 0.75rem 1.5rem;
      background: var(--accent);
      border: none;
      border-radius: 8px;
      color: white;
      font-size: 1rem;
      cursor: pointer;
    }

    .btn:hover { background: var(--accent-hover); }

    .guesses {
      margin: 1.5rem 0;
      display: flex;
      flex-direction: column;
      gap: 0.5rem;
    }

    .guess {
      padding: 0.5rem 1rem;
      background: var(--bg);
      border-radius: 4px;
      display: flex;
      justify-content: space-between;
    }

    .guess.correct { background: rgba(74, 222, 128, 0.2); color: var(--success); }
    .hint { font-size: 0.875rem; }
    .hint.higher { color: #f4b752; }
    .hint.lower { color: #4fc3f7; }

    .result {
      font-size: 1.5rem;
      margin: 1rem 0;
    }

    .result.win { color: var(--success); }
    .result.lose { color: var(--error); }

    .hidden { display: none; }
  </style>
</head>
<body>
  <div class="container">
    <h1>🔢 Deviner le nombre</h1>
    <p class="info">Trouvez le nombre entre 1 et 100</p>

    <div id="game-area">
      <div class="input-group">
        <input type="number" id="guess-input" min="1" max="100" placeholder="?">
        <button class="btn" id="btn-guess">Deviner</button>
      </div>

      <p id="remaining">7 essais restants</p>

      <div class="guesses" id="guesses"></div>
    </div>

    <div id="result-area" class="hidden">
      <p class="result" id="result"></p>
      <p id="answer"></p>
      <button class="btn" id="btn-restart">Rejouer</button>
    </div>
  </div>

  <script type="module">
    import { GuessNumberEngine } from './engine.js';

    const engine = new GuessNumberEngine();
    let state = null;
    const humanId = 'player1';

    // Éléments
    const guessInput = document.getElementById('guess-input');
    const btnGuess = document.getElementById('btn-guess');
    const guessesEl = document.getElementById('guesses');
    const remainingEl = document.getElementById('remaining');
    const gameArea = document.getElementById('game-area');
    const resultArea = document.getElementById('result-area');
    const resultEl = document.getElementById('result');
    const answerEl = document.getElementById('answer');

    function startGame() {
      state = engine.init({
        seed: Date.now(),
        playerIds: [humanId],
      });

      guessesEl.innerHTML = '';
      guessInput.value = '';
      gameArea.classList.remove('hidden');
      resultArea.classList.add('hidden');
      updateRemaining();
      guessInput.focus();
    }

    function updateRemaining() {
      const left = state.maxGuesses - state.guesses.length;
      remainingEl.textContent = `${left} essai${left > 1 ? 's' : ''} restant${left > 1 ? 's' : ''}`;
    }

    function makeGuess() {
      const num = parseInt(guessInput.value);
      if (isNaN(num) || num < 1 || num > 100) {
        guessInput.classList.add('error');
        return;
      }

      const action = { type: 'guess', number: num };
      if (!engine.isValidAction(state, action, humanId)) return;

      state = engine.applyAction(state, action, humanId);

      // Afficher le résultat
      const lastGuess = state.guesses[state.guesses.length - 1];
      const guessEl = document.createElement('div');
      guessEl.className = 'guess' + (lastGuess.hint === 'correct' ? ' correct' : '');
      guessEl.innerHTML = `
        <span>${lastGuess.number}</span>
        <span class="hint ${lastGuess.hint}">
          ${lastGuess.hint === 'higher' ? '⬆️ Plus grand'
          : lastGuess.hint === 'lower' ? '⬇️ Plus petit'
          : '✅ Correct !'}
        </span>
      `;
      guessesEl.appendChild(guessEl);

      guessInput.value = '';
      updateRemaining();

      if (state.gameOver) {
        showResult();
      } else {
        guessInput.focus();
      }
    }

    function showResult() {
      gameArea.classList.add('hidden');
      resultArea.classList.remove('hidden');

      if (state.winners) {
        resultEl.textContent = '🎉 Bravo !';
        resultEl.className = 'result win';
        answerEl.textContent = `Trouvé en ${state.guesses.length} essai${state.guesses.length > 1 ? 's' : ''}`;
      } else {
        resultEl.textContent = '😔 Perdu !';
        resultEl.className = 'result lose';
        answerEl.textContent = `Le nombre était ${state.secretNumber}`;
      }
    }

    // Events
    btnGuess.addEventListener('click', makeGuess);
    guessInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') makeGuess();
    });
    document.getElementById('btn-restart').addEventListener('click', startGame);

    // Démarrer
    startGame();
  </script>
</body>
</html>

Bonnes pratiques#

Architecture#

  1. Séparer les responsabilités : Le client ne contient que l'affichage et les événements, jamais la logique de jeu
  2. Un seul état : state est la source de vérité, toujours obtenu via le moteur
  3. Render explicite : Appeler render() après chaque changement d'état

UX#

  1. Feedback immédiat : L'interface réagit instantanément aux clics
  2. États visuels : Hover, disabled, loading clairement indiqués
  3. Messages clairs : "À vous", "Tour adverse", "Vous avez gagné"

Performance#

  1. Délai pour le bot : setTimeout(playBot, 500) pour que l'utilisateur voie le jeu
  2. Pas de re-render inutile : Ne mettre à jour que ce qui change
  3. Event delegation : Un seul listener sur le conteneur plutôt que sur chaque élément

Accessibilité#

  1. Boutons sémantiques : <button> plutôt que <div onclick>
  2. Labels : aria-label sur les boutons sans texte
  3. Focus : tabindex et gestion du focus clavier

Checklist#

  • Écran de configuration avec options
  • Écran de jeu avec plateau et statut
  • Import du moteur et des bots
  • Fonction startGame() initialise correctement
  • Fonction render() affiche l'état complet
  • Les clics déclenchent handlePlayerAction()
  • Le bot joue avec délai visible
  • Fin de partie affichée clairement
  • Boutons Rejouer et Menu fonctionnels
  • Raccourcis clavier (Escape, R)

Voir aussi#