Moviezz, ou comment lancer un jeu multijoueur en temps réel sans développeur·se front-end

Moviezz c’est avant tout l’histoire de Jean-Julien – web designer – et de sa maman qui, grands cinéphiles, aimeraient se défier sur un blind test 100 % ciné.

Petit hic, il leur manque quelques compétences tech pour mener à bien son projet. Jean-Julien se tourne vers Nicolas, mon compagnon, qui s’empresse de m’embarquer dans l’aventure. Je rêve déjà de les défier sur les films de mon enfance, à savoir Retour vers le futur et Une journée en enfer !

Blind test pour cinéphiles

C’est donc un site web qui propose un jeu multijoueur en temps réel.
Le principe est simple :

  • Le ou la joueuse rejoint une séance comportant des extraits de film.
  • Iel dispose de trente secondes par extrait pour deviner le nom du film ainsi que le réalisateur ou la réalisatrice.
  • Les trois premières personnes à trouver remportent plus de points que les autres.

Le site propose différentes catégories de séances : Blockbusters, Films Français, Science Fiction, etc.

Page d’accueil du jeu Moviezz

Mécanique de jeu

Comme au cinéma, la séance est la même pour tous et toutes. C’est-à-dire qu’à un instant donné, il y a une seule séance en cours de projection par catégorie.

Caractéristiques

  • Une séance comporte dix extraits de trente secondes.
  • Il y a un entracte de dix secondes entre chaque extrait.
  • Il y a un entracte de trente secondes entre chaque séance.
Diagramme de flux d’une séance

La gestion du temps est donc très importante dans la mécanique du jeu.

Le temps réel

Nous avions trois problématiques autour du temps réel :

  1. passage d’écran en écran ;
  2. action du joueur ;
  3. action d’un autre joueur.

Passage d’écran en écran

Dès l’instant où une personne rejoint une séance, le changement des écrans (entracte et projection) doit se faire automatiquement.
Soit après trente secondes de visionnage, soit après dix ou trente secondes d’entracte (voir schéma plus haut).

Il va de soi que si la personne recharge la page de son navigateur, il faut prendre en compte uniquement le temps restant.

Action du joueur

Lorsqu’un joueur soumet un nom de film ou de réalisateur, il ne doit pas y avoir de rechargement de page. L’extrait doit continuer d’être projeté, sans interruption.

Action d’un autre joueur

Lorsqu’un autre joueur soumet une bonne réponse, le classement de la séance doit être mis à jour pour l’ensemble des joueurs de la séance.

WebSocket & JavaScript

Le temps réel implique une communication bi-directionnelle entre le serveur et le navigateur web du joueur. Qui dit navigateur, dit WebSocket et JavaScript !

WebSocket

WebSocket est un protocole réseau de communication bi-directionnelle, implémenté au dessus du protocole TCP.
Il permet donc de :

  • Garder un canal de communication ouvert entre le serveur et le navigateur.
  • Envoyer et recevoir de la donnée depuis le navigateur.
  • Envoyer et recevoir de la donnée depuis le serveur.
Protocoles HTTP et WebSocket

JavaScript

Une partie de la communication se faisant côté navigateur web, cela implique que les messages (émis depuis le serveur et) reçus par le navigateur seront gérés par du code JavaScript.

Pour rappel, nous n’avons pas de développeur·se front-end dans l’équipe. Il nous fallait trouver une solution simple à mettre en place et avec le moins de JavaScript possible.

Boîte à outils

Notre jeu est codé en Ruby on Rails, un framework web Ruby implémentant le pattern MVC (Modèle-vue-contrôleur).

Nous y avons fait deux ajouts afin de gérer le temps réel : StimulusReflex et CableReady.

Mais avant de plonger dans ces deux outils, je vous propose de découvrir comment nous nous y sommes pris.

Stratégie d’implémentation

Nous avons opté pour une stratégie qui marche à tous les coups :

  1. D’abord implémenter la mécanique de jeu sans le temps réel.
  2. Ajouter le temps réel pour le joueur faisant une action.
  3. Ajouter le temps réel lorsqu’un autre joueur fait une action.

Mécanique de jeu sans temps réel

Dès l’instant où une joueuse rejoint une séance (aka screening), elle se retrouve sur la page de la séance.

À tout moment, elle peut se retrouver sur un des écrans suivants :

  • salle d’attente : la séance n’a pas encore commencé ;
  • salle d’attente : la joueuse a rejoint la séance en cours de route et un extrait (ou son entracte) est en cours de projection ;
  • visionnage d’un extrait ;
  • entracte entre deux extraits ;
  • entracte entre deux séances ;

L’écran à afficher est déterminé en fonction de l’instant auquel commence la séance (nous stockons en base un datetime) et de l’instant présent.

Prenons un exemple :

  1. La séance démarre à 21:54:00.
  2. Le premier extrait est projeté à 21:54:00 et dure trente secondes.
  3. Le premier entracte a lieu à 21:54:30 et dure dix secondes.
  4. Le deuxième extrait commence à 21:54:40.

La joueuse rejoint la séance à 21:53:45 soit quinze secondes avant le démarrage de la séance. Il est maintenant 21:54:10, la joueuse est en train de visionner le premier extrait de la séance.

Frise chronologique d’une séance

Quel que soit l’écran affiché, l’URL reste la même : /screenings/42 pour une séance portant l’identifiant 42.

La décision d’afficher tel ou tel écran est régie par le back-end.
Cela veut dire qu’à n’importe quel moment, si notre joueuse rafraîchit sa page, elle obtiendra une information cohérente.

Salle d’attente avec le message « Une nouvelle séance va bientôt démarrer. Tiens-toi prêt ! »

Nous avons implémenté les fonctionnalités suivantes sans temps réel :

  • Rejoindre une séance.
  • Afficher le bon écran de la séance.
  • Soumettre un nom de film ou de réalisateur.
  • Rejoindre la prochaine séance.

Afin de tester que notre mécanique fonctionnait, nous procédions à des rafraîchissements réguliers de la page de la séance. Notre jeu était fonctionnel !

Temps réel sur action du joueur (StimulusReflex)

Notre premier jalon de passé, nous voulions maintenant avoir un déroulé fluide sans que la page web ne se recharge ou que le joueur ait à la recharger manuellement.

C’est ici que StimulusReflex entre en scène.

C’est une brique technique qui permet d’obtenir du temps réel sans avoir à écrire la moindre ligne de JavaScript (ou presque). La particularité ici est que le temps réel est disponible uniquement pour la personne qui effectue l’action.

Premier objectif : soumettre un nom de film et de réalisateur sans que la page ne se rafraîchisse

Dans une application Ruby on Rails classique, l’URL /screenings/42 est gérée par le contrôleur ScreeningsController et son action (aka méthode) show.

Son code ressemble à ceci :

# app/views/screenings_controller.rb
class ScreeningsController < ApplicationController
  def show
    @screening = find_screening(params[:id])
    @step      = define_screening_step

    # calls a file called show.html.erb
    # HTML file with Embedded RuBy (aka ERB)
    render :show
  end

  private

  def find_screening(id)
    # [...]
  end

  def define_screening_step
    # [...]
  end
end

Pour l’écran de visionnage d’un extrait, dans notre fichier show.html.erb, nous avons deux éléments :

  • le lecteur de vidéo ;
  • le formulaire de soumission de la tentative du joueur.

Voici le formulaire une fois généré :

<form method="post" action="/screenings/42/screening_clips/1/attempts" data-reflex="submit->Attempt#submit">
  <input type="text" name="content" aria-label="Tentative" placeholder="Tape ici le réalisateur ou le film">
  <input type="submit" value="GO !">
</form>

La magie réside dans l’attribut de données data-reflex dont la valeur est submit->Attempt#submit, qui peut se traduire par :

Sur déclenchement de l’événement submit du formulaire, trouve le reflex Attempt et appelle la méthode submit.

Le reflex est une classe Ruby (back-end donc) ressemblant très fortement à la structure d’un contrôleur.

# app/reflexes/attempt_reflex.rb
class AttemptReflex < ApplicationReflex
  def submit
    @screening      = find_screening(params[:screening_id])
    @screening_clip = find_screening_clip(params[:screening_clip_id])
    @attempt        = find_or_create_player_attempt(params[:content])

    update_player_attempt
  end

  private

  def update_player_attempt
    # [...]
  end
end

Et c’est tout…
Sans JavaScript et avec ces quelques lignes de Ruby, la soumission d’un nom de film / réalisateur se fait sans rafraîchissement de la page, la joueuse obtient directement le résultat et le visionnage de l’extrait se poursuit.

Retour en temps réel sur soumission du formulaire

Voici ce qui se passe en détail derrière le rideau :

  1. StimulusReflex met en place un extrait de code JavaScript qui écoute tout data-reflex mis en place.
  2. Sur déclenchement de l’événement submit, StimulusReflex déclenche un appel au back-end via WebSocket.
  3. Le canal de communication StimulusReflex côté back-end reçoit l’information et instancie un AttemptReflex et appelle la méthode submit.
  4. Puis il instancie ensuite un ScreeningsController et appelle la méthode show.
  5. La méthode show génère la nouvelle version du fichier show.html.erb avec le résultat de la soumission du joueur.
  6. Le HTML est envoyé via WebSocket au navigateur.
  7. L’extrait de code JavaScript de StimulusReflex reçoit le nouveau contenu HTML et morph le DOM existant.
StimulusReflex : architecture et diagramme de flux

Oui, rien que ça !

C’est toute une gymnastique qui nous est épargnée.

Pour résumer, à ce stade, nous avons un jeu où la joueuse peut soumettre les noms de film et réalisateur et avoir un retour en temps réel mais pour le moment, elle doit encore rafraîchir sa page à chaque changement d’écran : salle d’attente, visionnage, entracte, etc.

Deuxième objectif : passage automatique des écrans

Nous avons décidé de ne pas surcharger le serveur. Le déclenchement du changement d’écran se fera côté navigateur.

Pour ce faire, nous avons dû écrire nos premières lignes de JavaScript. 😱

Tous les écrans ont une durée d’affichage. Par exemple, l’écran de visionnage dure trente secondes.

Nous avons placé dans le HTML le nombre de secondes restantes. Puis dans un extrait de code JavaScript, nous appliquons un setTimeout qui déclenchera un appel à la fonction stimulate – proposée par StimulusReflex – une fois le temps écoulé. Cette fonction permet de déclencher manuellement un reflex.

const remaindingSeconds = extractRemainingSecondsFromDOM();

const screeningTimeout  = () => {
  stimulate('Screening#timeout');
})

setTimeout(screeningTimeout, remaindingSeconds * 1000) // in ms

Côté back-end, nous avons le reflex suivant :

# app/reflexes/screening_reflex.rb
class ScreeningReflex < ApplicationReflex
  def timeout
  end
end

Rien… Ou presque !

Par défaut, ce reflex déclenche l’appel de la méthode show du ScreeningsController, qui déclenche donc un morph de la page entière.
Notre joueur se retrouve maintenant sur la page d’entracte de l’extrait !

Entracte entre deux extraits

Nous avons donc un jeu complètement fonctionnel en temps réel mais pas encore multijoueur.

Temps réel sur action d’un autre joueur (CableReady)

Il nous reste donc une dernière action à mener avant que notre jeu soit complètement fonctionnel. Sur toute action d’un autre joueur, il faut que notre écran se mette à jour automatiquement et en temps réel.

C’est ici que nous utilisons CableReady.

C’est une autre brique technique qui permet notamment de déclencher des modifications du DOM depuis le back-end en passant par une communication WebSocket.

Architecture de CableReady
Architecture de CableReady

Mise à jour du classement de la séance

Lorsqu’une autre joueuse soumet un nom de film ou de réalisateur avec succès, elle gagne des points. Il se peut donc qu’elle change de position dans le classement.

Ce changement de position doit être reflété auprès de tous les joueurs présents dans la séance. Nous avons donc besoin d’avoir un canal de communication bi-directionnel (aka WebSocket) entre le back-end et le navigateur web.

Voici l’orchestration attendue :

  1. Une joueuse soumet une tentative fructueuse.
  2. Le back-end déclenche une communication contenant le nouveau classement auprès des navigateurs web de tous les joueurs.
  3. Le navigateur web reçoit cette communication et met à jour le classement.

Canal de communication

Nous avons besoin de définir un canal de communication qui sera propre à chaque séance.
Côté back-end, un canal (aka channel) est une simple classe Ruby.

class ScreeningsChannel < ApplicationCable::Channel
  def subscribed
    stream_for Screening.find(params[:id])
  end
end

Cette classe permet de gérer autant de canaux qu’il y a de séances dans la base de données. C’est le paramètre id qui permet de faire la distinction entre chaque séance.

Souscription au canal

Côté navigateur, nous devons nous abonner au canal de communication dédié à la séance sur laquelle nous nous trouvons. Le fait de s’abonner va nous permettre de recevoir toute communication provenant du back-end.

import CableReady from 'cable_ready'
import consumer   from 'channels/consumer'

const screeningId = extractScreeningIdFromDOM();

consumer.subscriptions.create(
  {
    channel: 'ScreeningsChannel',
    id:      screeningId
  },
  {
    received (data) {
      if (data.cableReady) {
        CableReady.perform(data.operations)
      }
    }
  }
)

On retrouve ici le même nom de canal ScreeningsChannel ainsi que le paramètre id. Nous sommes prêts !

Envoi du classement

Pour rappel, nous avons déjà du temps réel pour la personne qui soumet sa tentative. Voici à nouveau le reflex qui s’en charge :

# app/reflexes/attempt_reflex.rb
class AttemptReflex < ApplicationReflex
  def submit
    @screening      = find_screening(params[:screening_id])
    @screening_clip = find_screening_clip(params[:screening_clip_id])
    @attempt        = find_or_create_player_attempt(params[:content])

    update_player_attempt
  end

  private

  # [...]
end

Nous allons mettre à jour ce reflex afin de diffuser le nouveau classement à l’ensemble des joueurs de la séance.

# app/reflexes/attempt_reflex.rb
class AttemptReflex < ApplicationReflex
  def submit
    @screening      = find_screening(params[:screening_id])
    @screening_clip = find_screening_clip(params[:screening_clip_id])
    @attempt        = find_or_create_player_attempt(params[:content])

    update_player_attempt
    broadcast_leaderboard_update # NEW
  end

  private

  def broadcast_leaderboard_update
    html = render_leaderboard

    cable_ready[ScreeningsChannel].inner_html(
      selector: '#leaderboard',
      html:     html
    ).broadcast_to(@screening)
  end

  def render_leaderboard
    # [...]
  end
end

On déclenche une communication auprès du canal ScreeningsChannel mais scopé sur la séance @screening – ce qui infère son id. La communication contient une opération CableReady qui indique au navigateur d’exécuter les commandes suivantes :

  1. Trouve l’élément dans le DOM portant l’identifiant leaderboard.
  2. Remplace son contenu HTML avec le HTML fourni.

C’est la ligne suivante côté navigateur qui s’occupe d’interpréter et exécuter l’opération :

CableReady.perform(data.operations)
CableReady : diagramme de flux

Et voilà ! Vraiment. C’est fonctionnel.

Avec un extrait de code JavaScript fourni par CableReady et avec quelques lignes de Ruby, le classement se met à jour instantanément sur tous les écrans des joueurs de la séance et ce sans rafraichissement de la page ni arrêt du visionnage de l’extrait.

Classement des joueurs de la séance

Stimulus

Je n’ai pas eu l’occasion de mentionner Stimulus dont le nom transparait dans StimulusReflex et qui est agnostique de tout framework.

C’est une toute petite brique JavaScript qui permet de « stimuler » votre DOM en positionnant des attributs de données qui vont venir se connecter automatiquement à des contrôleurs JavaScript.

Je vous laisse avec un exemple très courant :

Au clic sur le lien de classement de la séance, faire apparaitre ledit classement (voir écran juste au dessus).

Voici les attributs de données :

<div data-controller="leaderboard">
  <button type="button" data-action="click->leaderboard#toggle">Classement</button>

  <ol class="hidden" data-leaderboard-target="list">
    <li>1. rakido 18 pts</li>
    <li>2. cecile  8 pts</li>
    <li>3. nfilzi  7 pts</li>
  </ol>
</div>

Et le contrôleur :

// leaderboard_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "list" ]

  toggle() {
    this.listTarget.classList.toggle("hidden")
  }
}

2 devs back-end + 1 web designer <3

En fin de course, nous avons la base fonctionnelle de notre jeu temps réel multijoueur avec une vingtaine de lignes de JavaScript dont trois effectives :

  • Stimuler un reflex sur timeout.
  • Souscription à un canal spécifique.
  • Exécution des opérations CableReady reçues depuis le back-end.

Le seul inconvénient à cette technique est que le back-end a impérativement un minimum connaissance de comment le DOM est construit.
Pour un projet géré en full-stack par les mêmes développeurs·ses, c’est jouable. 😉

J’espère que cet article vous aura donné envie de tester le temps réel en mode « back-end » ou tout simplement donné envie de tester notre jeu.

Ressources