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.
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é
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 :
Et voici le code de la commande en question :
La fonction
La variable
La fonction
La fonction
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 :
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
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