Mon projet actuel : Toxic, un simple quiz, avec un twist !
#1
Après avoir réalisé un quiz pour faire une démo du framework Phoenix pour la boîte dans laquelle je bosse, j'ai eu envie de continuer le projet ... sauf que les questions étaient un peu génériques et pas très amusantes. La culture générale ça va bien cinq minutes.

J'ai donc eu envie de proposer un quiz avec des questions marrantes, mais ce n'est pas évident. C'est alors que j'ai eu cette idée : pour que les questions soient intéressantes, il faut qu'elles engagent les joueurs et joueuses. Sauf que évidemment le jeu ne sait absolument rien des joueurs. J'ai trouvé une solution simple : ce sont les joueurs qui choisissent la bonne réponse pour chaque question. Par exemple "Qui ferait bien de prendre rendez-vous chez le coiffeur". Le twist étant que pour répondre à cette questions, les choix proposés sont les joueurs eux-mêmes – le but étant de se chambrer gentiment. Je pense aussi pouvoir activer un mode "entre amis relax qui se connaissent bien" pour proposer des questions plus méchantes, d'où le nom du jeu. Voilà voilà, c'est pas vraiment uuuultra drôle vous remarquerez, je n'ai pas vraiment d'imagination pour les questions ; j'espère en trouver des sympa à l'avenir.

Le principe est simple, il faut réunir au moins trois joueurs et lancer une partie. La personne qui crée la partie choisit le nombre de questions par joueurs, entre 1 et 5. Donc, avec trois joueurs et deux questions par joueur, ça nous fait six questions. Oui, je sais, des maths de haut niveau à minuit et demie pour moi non plus ce n'est pas évident, mais faites un effort.

On lance donc la partie, et chacun son tour un joueur devient le "question master" : il choisit un thème parmi trois proposés aléatoirement, et le jeu tire une question au hasard dans ce thème. Le joueur voit ensuite les réponses possibles, c'est à dire les autres joueurs, et choisit la bonne réponse.

Les joueurs ont ensuite un temps limité pour donner leur réponse, et quand tout le monde a répondu, ou que le temps imparti s'est écoulé, on affiche le résultat, puis on passe au tour suivant.

Quand toutes les questions ont été posées, on affiche les scores et c'est fini.

Le projet est donc plutôt simple, ce qui me permet de me concentrer sur l'architecture. Il y a quand même quelques détails supplémentaires :

- Le jeu est conçu pour que tout le monde sache qui est le "question master". Ce dernier, au moment de choisir la bonne réponse, voit l'intitulé ainsi : « Selon vous, qui est le plus bla bla bla ». Au moment de choisir leurs réponses, les autres joueurs voient « Selon <nom du "question master">, qui est le plus bla bla bla ».

- Je pense proposer trois types de questions : celles dont les réponses possibles sont les joueurs, celles dont les réponses possibles sont les joueurs mais pas le "question master", et enfin quelques questions avec des réponses prédéfinies (pas les joueurs donc, plus classique) mais toujours en rapport avec le "question master".

- Les questions ont un "rage level". Cela définit le potentiel de toxicité de la question, c'est à dire sa capacité à vous faire perdre vos amis. Pour le moment je planche sur des questions de niveau 0 et 1, c'est à dire anodines ou qui chambrent gentiment, des trucs sympas qui passent partout. J'aimerais ensuite pouvoir créer des questions de niveau plus élevé, avec des trucs potentiellement méchants, mais c'est lors de la création de partie qu'il faut définir le niveau maximum. Je pense que celui qui crée la partie choisira le niveau max au lieu de faire un genre de vote, mais le niveau sera affiché à tout le monde. Il faudrait éventuellement demander aux joueurs de confirmer leur participation si le niveau max est supérieur à 1.

- Quand j'aurai un premier prototype (très bientôt), je testerai avec des amis, le temps limité sera court, par exemple dix secondes. Si c'est amusant, je pourrais éventuellement plancher sur une version plus "asynchrone" pour Facebook, ou on a quelques heures pour répondre. Mais je pense que ça peut casser l'ambiance du jeu.

Côté technique, c'est ma stack de prédilection : Phoenix/Elixir pour la partie backend, Svelte 3 pour la partie JS / vues et Bulma pour les styles. Je ne me complique vraaaaiiiiment pas pour les styles par contre, c'est beaucoup trop relou. Au fond je sais très bien implémenter un design mais pas l'imaginer.

Pour gérer une partie, j'utilisais un Agent (un genre d'Actor, un processus Elixir) pour garder l'état de ma partie et recevoir les input des joueurs. Mais ça n'était pas vraiment pratique. Inspiré par des décisions techniques pour mon super-projet-qui-ne-verra-jamais-le-jour et le post sur Seelies de Sephi-chan, j'ai implémenté un système de commandes tout simple pour le jeu.

L'architecture backend est donc basée sur un mutex que j'ai créé. À chaque input utilisateur, on lock une entité (ou plusieurs) sur le mutex, et on envoie la commande à l'outil qui centralise tout ça.
J'ai cherché un nom pour le code qui va gérer ça, et comme mon super-projet-un-jour-peut-être est basé sur des vaisseaux (super original), je l'ai appelé Skipper (si vous trouvez mieux n'hésitez pas).

Voici par exemple le code qui reçoit le choix de thème du "question master", et le code générique qui gère toutes les commandes dans le channel :


  def handle_in("choose_theme", %{"theme" => theme}, socket) do
    party_id = get_party_id(socket)
    player_id = user_id(socket)
    command = Party.Command.ChooseTheme.new(party_id, player_id, theme)

    run_command(command, socket)
  end

  # ...

  defp run_command(command) do
    Skipper.run(Toxic.SMG, command)
  end

  defp run_command(command, socket, success_value \\ :ok) do
    case run_command(command) do
      :ok -> {:reply, success_value, socket}
      {:error, _} = err -> {:reply, json_error(err), socket}
    end
  end 

Et voici le code de la commande en question :


defmodule Toxic.Game.Party.Command.ChooseTheme do
  alias Toxic.Game.Party.State
  alias Toxic.Game
  alias Toxic.Game.Question
  import Toxic.Game.Party.Command.Helpers

  defstruct [:party_id, :player_id, :theme]

  def new(party_id, player_id, theme) do
    %__MODULE__{party_id: party_id, player_id: player_id, theme: theme}
  end
 
  def key_spec(%__MODULE__{party_id: p}),
    do: {Toxic.Game.Party, p}

  def check(%__MODULE__{player_id: player_id}, state) do
    with :ok <- check_status(state, :awaiting_question) do
      State.check_qmaster(state, player_id)
    end
  end

  def run(%__MODULE__{theme: theme, party_id: party_id}, state) do
    prev_qids = State.get_previous_questions_ids(state)

    case Game.pick_random_question_from_theme(theme, prev_qids) do
      %Question{} = question ->
        with {:ok, state} <-
              State.put_question(state, question) do
          {:ok, update: state, status_change: {party_id, state.status}}
        end

      nil ->
        {:error, :no_question_found}
    end
  end
end


La fonction key_spec indique les entités qui vont être lockées sur le mutex. Ceci permet de recevoir plein de commandes de plusieurs joueurs en simultané, mais de les traiter à la queue si elles touchent à une même entité. Je n'ai donc pas de transaction sérialisable à gérer, et chaque commande qui est exécutée reçoit une entité à jour.

La variable state représente l'état d'une partie. En effet la commande ne reçoit pas directement la partie de la base de données, cette dernière est transformée en structure Party.State, plus simple à manipuler.

La fonction check permet de vérifier qu'on peut traiter la commande. Elle ne doit rien modifier. Elle est facultative car on peut tout aussi bien faire des vérifications dans run mais par convention on sait que check peut être exécutée plusieurs fois sans modifier le jeu.

La fonction run exécute la commande proprement dit : elle modifie l'état du jeu. Elle renvoie une liste d'évènements et d'entités modifiées à sauvegarder et une réponse au code qui a lancé la commande (tout est facultatif).
Certains évènements sont transmis au clients, comme un update par exemple, ce qui permet de mettre à jour la partie côté JS.

Voilà, c'est assez basique et pas très bien raffiné, mais ça marche nickel : on garde une logique basique de "je prends ma partie, je luis rajoute une nouvelle question, je sauvegarde, stop". La commande s'exécute dans le processus d'appel, ne touche pas la base de données, ni n'envoie les events directement – c'est simple à tester.

Le client est connecté à une partie via un websocket. On peut joueur plusieurs parties à la fois, et chaque partie à son channel phoneix

Je n'ai pas grand chose à raconter sur la partie javascript, Svelte 3 est simple, un peu trop magique parfois. Svelte 2 en gros c'était VueJS mais avec des données immutables (si on le choisit).

Svelte 3 est un compilateur plus avancé, le code est donc beaucoup plus simple mais un peu plus "tricky" au niveau de la réactivité quand on crée à la volée des channels phoenix. J'ai résolu ça en attendant que le channel soit ouvert pour afficher la vue : par exemple quand on va sur un autre partie que celle en cours, on ouvre le channel de la partie et on ferme celui qui était ouvert, dans ce composant :


<script>
    import { onDestroy } from 'svelte'
    import container from '@/container'
    import Party from '@/components/Party.svelte'
    export let router = {}
    const { party_id } = router.params
    const { partyConnect } = container

    const pChannel = partyConnect(party_id)
    onDestroy(() => pChannel.then(channel => {
        channel.leave()
    }))
</script>


{#await pChannel}
    <p>Connecting to the party …</p>
{:then channel}
    <Party {party_id} {channel}/>
{:catch err}
    <p>Error: {err.reason || err}</p>
{/await}

Bon en vrai ce code n'est plus utilisé. Je n'aime pas manipuler un channel directement dans le code, j'ai donc écrit un wrapper pour le channel d'une partie qui définit les appels possibles à ce channel et qui crée un store svelte pour mettre à jour l'état de la partie automatiquement.

Allez, j'arrête là mon pavé. C'est bien de décrire son jeu, ça m'a permis de trouver un bug 16
Répondre
#2
Sympa ! Ravi d'avoir pu t'inspirer ! 2 Le code entier est-il dispo quelque part ?

Je n'ai pas compris l'intérêt du mutex puisque les messages envoyés à un GenServer sont déjà sérialisés dans sa mailbox. Peux-tu expliquer ?
Répondre
#3
Ça ressemble un peu au jeu du "qui pourrait ?" ( ou )

Quitte à faire méchant, j'aurais bien vu aussi des achievement qui se débloquent au niveau du profil du joueur selon certaines réponses (genre "gros poivrot", "chaud du cul", "sociopathe", etc.).
Répondre
#4
Il y a aussi des jeux de sociétés avec un principe similaire.
Répondre
#5
Oui il y a des jeux comme le Privacy, le Tribunal, le Focus qui peuvent y faire penser 16
Répondre
#6
J'ai absolument pas cherché s'il y avait d'autres jeux dans le style. Est-ce que tu aurais des liens vers ces jeux ?
Répondre
#7
Je pense que Trapez parle de :
- privacy
- le tribunal
- focus
Répondre
#8
Han, J'avais pas pigé que c'était des jeux physiques en lisant le message de Trapez.

Merci ! Effectivement on est un peu à l'intermédiaire des trois. C'est cool, ça veut dire qu'il y a un public pour ce genre de trucs.
Répondre
#9
Ah mais il bug complètement le forum, il m'avait directement envoyé au message de Trapez et j'avais raté les précédents.

Citation : Sympa ! Ravi d'avoir pu t'inspirer ! 2 Le code entier est-il dispo quelque part ?

Nope, c'est closed source pour le moment (ce qui est un peu con mais bon …) mais si tu veux un accès y a pas de problème.

Citation :Je n'ai pas compris l'intérêt du mutex puisque les messages envoyés à un GenServer sont déjà sérialisés dans sa mailbox. Peux-tu expliquer ?

Il n'y a pas de GenServer, j'utilisais un Agent mais je l'ai viré. Le code est donc exécuté directement dans le channel (bon, qui est aussi un GenServer mais c'est une autre histoire 2 )

Citation :Ça ressemble un peu au jeu du "qui pourrait ?"

Ah oui ! tu connais le gameplay de ce jeu ? Il faut choisir la bonne réponse aussi ? Après si j'ai un gameplay identique ça ne me gêne pas, c'est pas non plus un truc hyper original.

Citation :Quitte à faire méchant, j'aurais bien vu aussi des achievement qui se débloquent au niveau du profil du joueur selon certaines réponses (genre "gros poivrot", "chaud du cul", "sociopathe", etc.).

Ouais j'avais pensé à balancer des emotes après certaines questions, je sais pas encore comment présenter ça, faut d'abord que je voie comment ça se joue en vrai.


Merci pour vos retours !
Répondre
#10
(08-13-2019, 01:38 PM)niahoo a écrit :
Citation :Ça ressemble un peu au jeu du "qui pourrait ?"
Ah oui ! tu connais le gameplay de ce jeu ? Il faut choisir la bonne réponse aussi ? Après si j'ai un gameplay identique ça ne me gêne pas, c'est pas non plus un truc hyper original.

C'est juste un jeu d'apéro : il y en a un qui pose une question (qui a pour réponse un joueur), tout le monde désigne quelqu'un et ceux qui se sont fait pointer du doigt boivent un coup.
Répondre




Utilisateur(s) parcourant ce sujet : 1 visiteur(s)