header_killer

Après une soirée au pub avec des amis, on s'est retrouvés à jouer aux fléchettes, au jeu Killer. C'est un super jeu, chaotique et social. Mais tenir le score sur un bout de papier ou dans les notes de son téléphone, c'est vite le bazar. Quelqu'un perd le fil, un autre conteste un point, et la moitié du groupe ne voit même pas le score.

Sur le chemin du retour, l'idée m'est venue : il faudrait un vrai tableau de score en direct. Quelqu'un met à jour et tous les téléphones à la table se rafraîchissent automatiquement. Un projet de weekend était né.

Le résultat, c'est dart-killer.eu — un tableau de score Killer gratuit, sans création de compte. On scanne un QR code et tout le monde à la table suit le même score en direct.

screen1_fr

Le jeu, en bref

Pour ceux qui ne connaissent pas : chaque joueur lance avec sa main non-dominante pour revendiquer un numéro (1–20). Une fois que vous avez accumulé 3 points sur votre numéro, vous devenez Killer. Les Killers marquent des points en touchant les numéros des autres, mais si un Killer touche son propre numéro, il perd des points. Tomber en dessous de zéro, c'est l'élimination. Le dernier debout gagne.

La logique de score est simple, mais avec assez de cas particuliers (que se passe-t-il si le deuxième-à-dernier joueur est éliminé par accident ?) pour mériter un vrai code plutôt qu'un calcul à la main.

La stack

Je voulais me servir de ce projet pour tester des features Symfony que je n'avais pas encore combinées :

  • Symfony 8 / PHP 8.5
  • Twig Components + Live Components (Symfony UX)
  • Mercure + Turbo Streams pour la sync en temps réel entre appareils
  • FrankenPHP (mode worker en production)
  • Symfony AssetMapper — sans Webpack, sans bundler
  • PostgreSQL pour la persistance
  • EasyAdmin 5 pour un panneau d'administration minimal
  • Justfile pour l'automatisation des tâches
  • Docker multi-stage, déployé via Dokploy

Le modèle de données

Le jeu progresse à travers quatre statuts gérés par un enum GameStatus :

enum GameStatus: string
{
    case Setup = 'setup';
    case NumberAssignment = 'number_assignment';
    case Active = 'active';
    case Finished = 'finished';
}

Les deux entités principales sont Game (UUID v7 en clé primaire, statut, timestamps) et GamePlayer (UUID v7, nom, numéro assigné, points, flag d'élimination, ordre de passage).

La décision clé a été de ne pas stocker isKiller en base. C'est entièrement calculé :

public function isKiller(): bool
{
    return $this->points === self::MAX_POINTS && !$this->isEliminated;
}

La gestion des points vit directement sur l'entité et fait respecter les règles du jeu comme des invariants :

public function addPoint(): void
{
    if ($this->isEliminated || $this->points >= self::MAX_POINTS) {
        return;
    }
    $this->points++;
}

public function removePoint(): void
{
    if ($this->isEliminated) {
        return;
    }
    $this->points--;
    if ($this->points < self::MIN_POINTS) {
        $this->isEliminated = true;
    }
}

Comme ça, aucun controller ou service ne peut accidentellement mettre un joueur dans un état invalide.

Le routing comme machine à états

GameController joue le rôle de gardien de la machine à états. Chaque route vérifie le statut actuel de la partie et redirige vers le bon écran si quelque chose ne va pas :

private function redirectToStatus(Game $game): RedirectResponse
{
    return $this->redirect(match ($game->getStatus()) {
        GameStatus::Setup            => $this->generateUrl('app_game_setup', ['id' => $game->getId()]),
        GameStatus::NumberAssignment => $this->generateUrl('app_game_assign', ['id' => $game->getId()]),
        GameStatus::Active           => $this->generateUrl('app_game_play', ['id' => $game->getId()]),
        GameStatus::Finished         => $this->generateUrl('app_game_winner', ['id' => $game->getId()]),
    });
}

Si vous essayez d'ouvrir la page de jeu alors que la partie est encore en configuration, vous vous retrouvez sur l'écran de setup. Pas d'état invalide possible via une mauvaise URL.

La cible de fléchettes : un générateur SVG en PHP pur

L'un des aspects les plus amusants de ce projet a été de construire la cible. Je ne voulais pas utiliser un fichier SVG statique ni une librairie externe. Je voulais qu'elle soit générée dynamiquement pour pouvoir réagir à l'état du jeu : afficher les noms des joueurs sur les segments, mettre en évidence les Killers, marquer les numéros pris.

DartboardComponent est un Twig Component (côté serveur uniquement, sans mise à jour live) qui génère tous les arcs SVG mathématiquement en PHP. Soyons honnêtes, le calcul des arcs, c'est Claude qui l'a fait. Je savais ce que je voulais — une vraie géométrie de cible avec le bon ordre des segments, les bons rayons et la conversion polaire-vers-SVG — mais c'est le genre de chose où on préfère décrire ce qu'on veut et laisser une IA générer la formule plutôt que de s'y coller soi-même à 23h un samedi.

#[AsTwigComponent('Dartboard')]
class DartboardComponent
{
    private const SEGMENT_ORDER = [20, 1, 18, 4, 13, 6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5];

    private const R_BULL         = 12.5;
    private const R_BULL_25      = 25.0;
    private const R_INNER        = 100.0;
    private const R_TRIPLE_IN    = 107.0;
    private const R_TRIPLE_OUT   = 115.0;
    private const R_OUTER        = 162.0;
    private const R_DOUBLE_IN    = 170.0;
    private const R_DOUBLE_OUT   = 178.0;
    private const R_LABEL        = 195.0;
    private const R_PLAYER_LABEL = 130.0;

    private function arc(float $r1, float $r2, float $startDeg, float $endDeg): string
    {
        // convertit des coordonnées polaires en chemin d'arc SVG (M ... L ... A ... L ... A ... Z)
        $cx = 200; $cy = 200; // centre du viewBox 400x400
        // ... calcul des arcs ...
    }
}

La méthode getSegments() retourne tous les chemins d'arc pour chaque anneau (simple intérieur, triple, simple extérieur, double) ainsi que les coordonnées de position du label joueur dans chaque segment.

Le même composant sert deux rôles selon la prop interactive :

  • Écran d'assignation (interactive: true) : chaque segment devient une zone cliquable reliée à une action Live Component :
{% if interactive %}
    <path class="dartboard__hit-zone {{ seg.number in takenNumbers ? '--taken' }}"
          d="{{ seg.fullPath }}"
          data-action="click->live#action"
          data-live-action-param="assignNumber"
          data-live-number-param="{{ seg.number }}"
    />
{% endif %}
  • Écran de jeu (interactive: false) : les noms des joueurs et l'emoji killer sont superposés comme éléments <text> à la position calculée pour chaque segment.

screen2_fr screen3_fr screen4_fr

Live Components : l'interaction sur un seul appareil

Les Live Components Symfony UX gèrent l'interactivité sur l'appareil qui effectue l'action. Les deux composants clés sont GameAssignComponent et GameBoardComponent.

GameAssignComponent

Sur l'écran d'assignation des numéros, ce Live Component enveloppe la cible. Quand un joueur appuie sur un segment, l'action Stimulus déclenche une requête AJAX vers l'action live assignNumber :

#[AsLiveComponent('GameAssign')]
class GameAssignComponent
{
    use DefaultActionTrait;

    public string $gameId;

    #[LiveAction]
    public function assignNumber(#[LiveArg] int $number): void
    {
        $game = $this->gameRepository->find($this->gameId);
        $allAssigned = $this->gameService->assignNumber($game, $number);

        if ($allAssigned) {
            // optionnellement, démarrer automatiquement...
        }
    }

    #[LiveAction]
    public function undoAssignment(): void
    {
        $game = $this->gameRepository->find($this->gameId);
        $this->gameService->undoLastAssignment($game);
    }

    #[LiveAction]
    public function startGame(): Response
    {
        $game = $this->gameRepository->find($this->gameId);
        $this->gameService->startActiveGame($game);
        return new RedirectResponse($this->urlGenerator->generate('app_game_play', ['id' => $this->gameId]));
    }
}

Après chaque action, le composant est re-rendu côté serveur et le navigateur met à jour uniquement la section de la cible. Pas de rechargement de page, pas de JavaScript personnalisé.

GameBoardComponent

Le tableau de score principal pendant une partie active. addPoint et removePoint mettent à jour la base et appellent publish() pour diffuser vers les autres appareils (voir plus bas).

Il y a un petit détail UX important : que se passe-t-il quand le dernier joueur est éliminé ? Plutôt qu'appeler finishGame() directement, le composant entre dans un état pending winner :

#[LiveAction]
public function removePoint(#[LiveArg] string $playerId): void
{
    $game = $this->gameRepository->find($this->gameId);
    $winner = $this->gameService->removePoint($game, $playerId);

    if ($winner !== null) {
        $this->pendingWinnerId = $winner->getId()->toRfc4122();
    }

    $this->publish();
}

#[LiveAction]
public function confirmWinner(): Response
{
    $game = $this->gameRepository->find($this->gameId);
    $this->gameService->finishGame($game);
    $this->publish(gameFinished: true);
    return new RedirectResponse(/* page du gagnant */);
}

#[LiveAction]
public function cancelWinner(): void
{
    // restaure le joueur éliminé via reflection (contourne le setter privé)
    $this->gameService->restorePlayer($this->pendingWinnerId);
    $this->pendingWinnerId = null;
    $this->publish();
}

Le re-rendu affiche une overlay de confirmation : "Es-tu sûr que X a gagné ?" avec un bouton confirmer et annuler. Les mauvais clics arrivent, surtout après quelques manches.

screen5_fr

Mercure + Turbo Streams : sync entre appareils

Voici la partie qui donne l'impression que tout ça tient de la magie. À chaque appel de publish() sur le GameBoardComponent, un fragment Turbo Stream est rendu et poussé vers tous les appareils connectés via le hub Mercure :

private function publish(bool $gameFinished = false): void
{
    $html = $this->twig->render('broadcast/play.stream.html.twig', [
        'gameId'          => $this->gameId,
        'pendingWinnerId' => $this->pendingWinnerId,
        'gameFinished'    => $gameFinished,
    ]);

    $this->hub->publish(new Update('game/' . $this->gameId, $html));
}

Le template de diffusion enveloppe un re-rendu complet du composant GameBoard dans une action Turbo Stream :

{# broadcast/play.stream.html.twig #}
{% if gameFinished %}
    <turbo-stream action="replace" target="game-board-{{ gameId }}">
        <template>
            <script>window.location.href = "{{ path('app_game_winner', {id: gameId}) }}";</script>
        </template>
    </turbo-stream>
{% else %}
    <turbo-stream action="replace" target="game-board-{{ gameId }}">
        <template>
            <div id="game-board-{{ gameId }}">
                {{ component('GameBoard', { gameId: gameId, pendingWinnerId: pendingWinnerId }) }}
            </div>
        </template>
    </turbo-stream>
{% endif %}

Sur la page de jeu, s'abonner au topic Mercure se résume à une seule ligne :

<div {{ turbo_stream_listen('game/' ~ game.id.toRfc4122())|raw }}></div>

C'est tout. Turbo gère le flux SSE depuis Mercure et à chaque nouveau message, il effectue le remplacement dans le DOM automatiquement. Quand la partie se termine, tous les appareils connectés naviguent vers la page du gagnant.

La séparation des responsabilités est claire :

  • Live Component : gère l'interaction sur l'appareil qui a appuyé sur le bouton, re-rendu immédiat pour cette personne.
  • Mercure + Turbo Stream : diffuse le nouvel état à tous les autres téléphones à la table.

Un point à noter : pour l'instant, n'importe qui qui ouvre le lien de la partie a accès aux contrôles. Il peut ajouter et retirer des points comme celui qui a créé la partie. Au pub entre amis ça passe très bien, mais c'est quelque chose que je veux améliorer avec une séparation hôte/spectateur à un moment.

Docker et FrankenPHP

L'application tourne sur FrankenPHP en mode worker en production, ce qui démarre le kernel Symfony une seule fois et le garde en mémoire entre les requêtes. Le Dockerfile multi-stage gère proprement les deux environnements :

FROM dunglas/frankenphp:1.12.1-builder-php8.5.4-trixie AS base
# installer les extensions PHP : apcu, intl, opcache, gd, zip, pdo_pgsql

FROM base AS frankenphp_dev
RUN install-php-extensions xdebug
# installer php-cs-fixer, Symfony CLI, créer l'utilisateur app avec le UID/GID de l'hôte
CMD ["frankenphp", "run", "--watch"]

FROM base AS frankenphp_prod
COPY app .
RUN composer install --no-dev --optimize-autoloader \
    && composer dump-env prod \
    && php bin/console importmap:install \
    && php bin/console asset-map:compile
# les assets sont inclus dans l'image, pas de compilation à l'exécution

Le docker-entrypoint.sh gère le bootstrap au démarrage : warmup du cache et migrations automatiques à chaque lancement du conteneur.

php bin/console cache:warmup
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
exec docker-php-entrypoint "$@"

Pour le hub Mercure en développement, il tourne comme un conteneur séparé. J'ai choisi de le garder séparé de la capacité Mercure intégrée à FrankenPHP pour rendre la configuration explicite :

# compose.yaml (extrait)
mercure:
  image: dunglas/mercure:v0.21.11
  ports:
    - "${MERCURE_PORT:-8881}:80"
  environment:
    SERVER_NAME: ':80'
    MERCURE_PUBLISHER_JWT_KEY: '${MERCURE_JWT_SECRET}'
    MERCURE_SUBSCRIBER_JWT_KEY: '${MERCURE_JWT_SECRET}'
    MERCURE_EXTRA_DIRECTIVES: |
      cors_origins "*"
      anonymous

Le Justfile

J'utilise Just comme task runner sur tous mes projets récents. Il remplace le mélange de cibles Makefile et de scripts shell que j'avais avant. Voici une sélection de targets pour ce projet :

# Démarrer l'environnement de développement complet
dev:
    docker compose up -d
    @echo "App: http://localhost:${WEB_PORT:-8880}"

# Lancer PHP CS Fixer
cs:
    docker compose exec server php-cs-fixer fix --diff

# Lancer les tests
test:
    docker compose -f compose.test.yaml run --rm server php bin/phpunit

# Se connecter à la base de données
db:
    docker compose exec db psql -U${POSTGRES_USER} ${POSTGRES_DB}

# Ouvrir un shell dans le conteneur app
shell:
    docker compose exec server bash

# Lancer une commande Symfony console (usage : just console cache:clear)
console *args:
    docker compose exec server php bin/console {{args}}

# Construire l'image de production
build:
    docker build --target frankenphp_prod -t killer:latest ./docker/php

Just garde la charge cognitive basse. N'importe qui qui clone le projet peut lancer just dev et être opérationnel, sans avoir à lire une longue documentation dans le README.

CI/CD : de GitLab à Dokploy

La pipeline a deux jobs. Le premier construit et pousse l'image Docker de production vers mon registre privé :

build-docker-image:
  stage: build
  image: docker:27.3.1
  services:
    - docker:27.3.1-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build
        --target frankenphp_prod
        --cache-from $CI_REGISTRY/personal/killer:latest
        --build-arg GIT_SHA=$CI_COMMIT_SHA
        --build-arg GIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA
        --build-arg GIT_REF=$CI_COMMIT_REF_NAME
        --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
        -t $CI_REGISTRY/personal/killer:latest
        -f docker/php/Dockerfile .
    - docker push $CI_REGISTRY/personal/killer:latest
  only:
    - master
  changes:
    - app/**/*
    - docker/**/*

Le deuxième déclenche un redéploiement Dokploy via son API. Pas de SSH, pas de pull manuel. Juste un appel HTTP :

deploy_production:
  stage: deploy
  image: curlimages/curl:8.10.1
  script:
    - |
      curl -X POST "$DOKPLOY_URL/api/application.deploy" \
        -H "x-api-key: $DOKPLOY_API_KEY" \
        -H "Content-Type: application/json" \
        -d "{\"applicationId\": \"$DOKPLOY_APP_ID\"}"
  only:
    - master
  needs:
    - build-docker-image

Dokploy récupère la nouvelle image, effectue une mise à jour rolling via Docker Swarm, et l'ancien conteneur n'est arrêté qu'une fois le nouveau en bonne santé. Zéro downtime, et je reçois une notification Discord dans les deux cas.

Si vous voulez voir comment j'ai mis en place Dokploy lui-même, j'en ai parlé ici.

PWA et support offline

Comme les gens utilisent ça sur leur téléphone au pub, j'ai ajouté une PWA basique : un manifest.webmanifest (thème sombre, orientation portrait, icône SVG) et un service worker avec une stratégie cache-first pour les assets statiques et network-first pour le reste.

Le service worker ignore explicitement l'endpoint SSE Mercure et la route /admin :

// sw.js (extrait)
self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    if (url.pathname.startsWith('/.well-known/mercure')) return;
    if (url.pathname.startsWith('/admin')) return;

    if (url.pathname.startsWith('/assets/')) {
        event.respondWith(cacheFirst(event.request));
    } else {
        event.respondWith(networkFirstWithOfflineFallback(event.request));
    }
});

Pas indispensable pour ce type d'app, mais ça rend l'expérience "ajouter à l'écran d'accueil" bien plus fluide.

Pour conclure

Ce qui a commencé comme une idée sur le chemin du retour du pub s'est transformé en un projet weekend vraiment sympa. La combinaison Symfony Live Components + Mercure + Turbo Streams est étonnamment puissante pour ce genre de cas d'usage. Il n'y a presque pas de JavaScript personnalisé. Tout est piloté par des fragments HTML rendus côté serveur.

C'est en ligne sur dart-killer.eu. Si vous finissez par l'utiliser dans votre bar, dites-le moi en commentaire !

Bon code !