Files
ltbxd-actorle/docs/superpowers/plans/2026-03-30-game-persistence.md
thibaud-leclere 90ca2b946d docs: add game persistence implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 19:41:45 +02:00

21 KiB

Game Grid Persistence — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Persist game grids in the database so players can resume their current game, start new games explicitly, and abandon games in progress.

Architecture: Two new Doctrine entities (Game, GameRow) store the grid definition. A GameGridGenerator service encapsulates grid creation logic extracted from HomepageController. The homepage checks for an active game (via User or session) and renders either the grid or a "Start a game" button. A GameController handles start/abandon actions.

Tech Stack: Symfony 8, Doctrine ORM, PostgreSQL, Twig, React (unchanged components)


Task 1: Create the Game entity

Files:

  • Create: src/Entity/Game.php

  • Step 1: Create the Game entity

<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\GameRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: GameRepository::class)]
class Game
{
    public const string STATUS_IN_PROGRESS = 'in_progress';
    public const string STATUS_ABANDONED = 'abandoned';

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: true)]
    private ?User $user = null;

    #[ORM\ManyToOne(targetEntity: Actor::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?Actor $mainActor = null;

    #[ORM\Column(length: 20)]
    private string $status = self::STATUS_IN_PROGRESS;

    #[ORM\Column]
    private \DateTimeImmutable $startedAt;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $endedAt = null;

    /** @var Collection<int, GameRow> */
    #[ORM\OneToMany(targetEntity: GameRow::class, mappedBy: 'game', cascade: ['persist'], orphanRemoval: true)]
    #[ORM\OrderBy(['rowOrder' => 'ASC'])]
    private Collection $rows;

    public function __construct()
    {
        $this->startedAt = new \DateTimeImmutable();
        $this->rows = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

    public function setUser(?User $user): static
    {
        $this->user = $user;

        return $this;
    }

    public function getMainActor(): ?Actor
    {
        return $this->mainActor;
    }

    public function setMainActor(Actor $mainActor): static
    {
        $this->mainActor = $mainActor;

        return $this;
    }

    public function getStatus(): string
    {
        return $this->status;
    }

    public function setStatus(string $status): static
    {
        $this->status = $status;

        return $this;
    }

    public function getStartedAt(): \DateTimeImmutable
    {
        return $this->startedAt;
    }

    public function getEndedAt(): ?\DateTimeImmutable
    {
        return $this->endedAt;
    }

    public function setEndedAt(?\DateTimeImmutable $endedAt): static
    {
        $this->endedAt = $endedAt;

        return $this;
    }

    /** @return Collection<int, GameRow> */
    public function getRows(): Collection
    {
        return $this->rows;
    }

    public function addRow(GameRow $row): static
    {
        if (!$this->rows->contains($row)) {
            $this->rows->add($row);
            $row->setGame($this);
        }

        return $this;
    }

    public function abandon(): static
    {
        $this->status = self::STATUS_ABANDONED;
        $this->endedAt = new \DateTimeImmutable();

        return $this;
    }
}
  • Step 2: Commit
git add src/Entity/Game.php
git commit -m "feat: add Game entity"

Task 2: Create the GameRow entity

Files:

  • Create: src/Entity/GameRow.php

  • Step 1: Create the GameRow entity

<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\GameRowRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: GameRowRepository::class)]
class GameRow
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Game::class, inversedBy: 'rows')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Game $game = null;

    #[ORM\ManyToOne(targetEntity: Actor::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?Actor $actor = null;

    #[ORM\Column]
    private int $position;

    #[ORM\Column]
    private int $rowOrder;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getGame(): ?Game
    {
        return $this->game;
    }

    public function setGame(Game $game): static
    {
        $this->game = $game;

        return $this;
    }

    public function getActor(): ?Actor
    {
        return $this->actor;
    }

    public function setActor(Actor $actor): static
    {
        $this->actor = $actor;

        return $this;
    }

    public function getPosition(): int
    {
        return $this->position;
    }

    public function setPosition(int $position): static
    {
        $this->position = $position;

        return $this;
    }

    public function getRowOrder(): int
    {
        return $this->rowOrder;
    }

    public function setRowOrder(int $rowOrder): static
    {
        $this->rowOrder = $rowOrder;

        return $this;
    }
}
  • Step 2: Commit
git add src/Entity/GameRow.php
git commit -m "feat: add GameRow entity"

Task 3: Create the repositories

Files:

  • Create: src/Repository/GameRepository.php

  • Create: src/Repository/GameRowRepository.php

  • Step 1: Create GameRepository with findActiveForUser method

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Game;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Game>
 */
class GameRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Game::class);
    }

    public function findActiveForUser(User $user): ?Game
    {
        return $this->createQueryBuilder('g')
            ->andWhere('g.user = :user')
            ->andWhere('g.status = :status')
            ->setParameter('user', $user)
            ->setParameter('status', Game::STATUS_IN_PROGRESS)
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();
    }
}
  • Step 2: Create GameRowRepository (empty, standard)
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\GameRow;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<GameRow>
 */
class GameRowRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, GameRow::class);
    }
}
  • Step 3: Commit
git add src/Repository/GameRepository.php src/Repository/GameRowRepository.php
git commit -m "feat: add Game and GameRow repositories"

Task 4: Generate and run the database migration

Files:

  • Create: migrations/Version2026033000001.php (auto-generated)

  • Step 1: Generate the migration

make db:migration

Expected: A new migration file is created in migrations/ with CREATE TABLE game and CREATE TABLE game_row statements plus foreign keys.

  • Step 2: Review the generated migration

Read the generated migration file. Verify it contains:

  • game table with columns: id, user_id (nullable FK → user), main_actor_id (FK → actor), status, started_at, ended_at (nullable)

  • game_row table with columns: id, game_id (FK → game), actor_id (FK → actor), position, row_order

  • Proper foreign key constraints

  • Step 3: Run the migration

make db:migrate

Expected: Migration executes successfully.

  • Step 4: Commit
git add migrations/
git commit -m "feat: add migration for game and game_row tables"

Task 5: Create the GameGridGenerator service

Files:

  • Create: src/Service/GameGridGenerator.php

This extracts the grid generation logic from HomepageController::index (lines 25-72) into a dedicated service that creates and persists a Game with its GameRow entities.

  • Step 1: Create the service
<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Actor;
use App\Entity\Game;
use App\Entity\GameRow;
use App\Entity\User;
use App\Repository\ActorRepository;
use Doctrine\ORM\EntityManagerInterface;

class GameGridGenerator
{
    public function __construct(
        private readonly ActorRepository $actorRepository,
        private readonly EntityManagerInterface $em,
    ) {}

    public function generate(?User $user = null): Game
    {
        $mainActor = $this->actorRepository->findOneRandom(4);

        $game = new Game();
        $game->setMainActor($mainActor);
        $game->setUser($user);

        $usedActors = [$mainActor->getId()];
        $rowOrder = 0;

        foreach (str_split(strtolower($mainActor->getName())) as $char) {
            if (!preg_match('/[a-z]/', $char)) {
                continue;
            }

            $tryFindActor = 0;
            do {
                $actor = $this->actorRepository->findOneRandom(4, $char);
                ++$tryFindActor;
            } while (
                in_array($actor->getId(), $usedActors)
                || $tryFindActor < 5
            );

            $usedActors[] = $actor->getId();

            $row = new GameRow();
            $row->setActor($actor);
            $row->setPosition(strpos(strtolower($actor->getName()), $char));
            $row->setRowOrder($rowOrder);

            $game->addRow($row);
            ++$rowOrder;
        }

        $this->em->persist($game);
        $this->em->flush();

        return $game;
    }

    /**
     * Compute display data (grid, width, middle) from a Game entity for the React component.
     *
     * @return array{grid: list<array{actorName: string, actorId: int, pos: int}>, width: int, middle: int}
     */
    public function computeGridData(Game $game): array
    {
        $leftSize = 0;
        $rightSize = 0;
        $grid = [];

        foreach ($game->getRows() as $row) {
            $actor = $row->getActor();
            $pos = $row->getPosition();

            if ($leftSize < $pos) {
                $leftSize = $pos;
            }

            $rightSizeActor = strlen($actor->getName()) - $pos - 1;
            if ($rightSize < $rightSizeActor) {
                $rightSize = $rightSizeActor;
            }

            $grid[] = [
                'actorName' => $actor->getName(),
                'actorId' => $actor->getId(),
                'pos' => $pos,
            ];
        }

        return [
            'grid' => $grid,
            'width' => $rightSize + $leftSize + 1,
            'middle' => $leftSize,
        ];
    }
}
  • Step 2: Commit
git add src/Service/GameGridGenerator.php
git commit -m "feat: add GameGridGenerator service"

Task 6: Create the GameController

Files:

  • Create: src/Controller/GameController.php

  • Modify: config/packages/security.yaml (add /game routes as PUBLIC_ACCESS)

  • Step 1: Create the controller

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Game;
use App\Entity\User;
use App\Repository\GameRepository;
use App\Service\GameGridGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class GameController extends AbstractController
{
    #[Route('/game/start', name: 'app_game_start', methods: ['POST'])]
    public function start(
        Request $request,
        GameGridGenerator $generator,
        GameRepository $gameRepository,
    ): Response {
        $this->validateCsrfToken('game_start', $request);

        /** @var User|null $user */
        $user = $this->getUser();

        // Check no game already in progress
        if ($user) {
            $existing = $gameRepository->findActiveForUser($user);
        } else {
            $gameId = $request->getSession()->get('current_game_id');
            $existing = $gameId ? $gameRepository->find($gameId) : null;
            if ($existing && $existing->getStatus() !== Game::STATUS_IN_PROGRESS) {
                $existing = null;
            }
        }

        if ($existing) {
            return $this->redirectToRoute('app_homepage');
        }

        $game = $generator->generate($user);

        if (!$user) {
            $request->getSession()->set('current_game_id', $game->getId());
        }

        return $this->redirectToRoute('app_homepage');
    }

    #[Route('/game/{id}/abandon', name: 'app_game_abandon', methods: ['POST'])]
    public function abandon(
        Game $game,
        Request $request,
        EntityManagerInterface $em,
    ): Response {
        $this->validateCsrfToken('game_abandon', $request);

        /** @var User|null $user */
        $user = $this->getUser();

        // Verify ownership
        if ($user) {
            if ($game->getUser() !== $user) {
                throw $this->createAccessDeniedException();
            }
        } else {
            $sessionGameId = $request->getSession()->get('current_game_id');
            if ($game->getId() !== $sessionGameId) {
                throw $this->createAccessDeniedException();
            }
        }

        $game->abandon();
        $em->flush();

        if (!$user) {
            $request->getSession()->remove('current_game_id');
        }

        return $this->redirectToRoute('app_homepage');
    }

    private function validateCsrfToken(string $tokenId, Request $request): void
    {
        $token = $request->request->get('_token');
        if (!$this->isCsrfTokenValid($tokenId, $token)) {
            throw $this->createAccessDeniedException('Invalid CSRF token.');
        }
    }
}
  • Step 2: Add /game routes to security access_control

In config/packages/security.yaml, add a rule so /game routes are accessible to anonymous users (the controller handles auth logic itself):

    access_control:
        - { path: ^/login, roles: PUBLIC_ACCESS }
        - { path: ^/register, roles: PUBLIC_ACCESS }
        - { path: ^/$, roles: PUBLIC_ACCESS }
        - { path: ^/game, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: ROLE_USER }

The new line - { path: ^/game, roles: PUBLIC_ACCESS } must go before the catch-all ^/ rule.

  • Step 3: Commit
git add src/Controller/GameController.php config/packages/security.yaml
git commit -m "feat: add GameController with start and abandon actions"

Task 7: Update HomepageController to use persisted games

Files:

  • Modify: src/Controller/HomepageController.php

Replace the on-the-fly grid generation with a lookup of the active game.

  • Step 1: Rewrite HomepageController::index

Replace the entire content of src/Controller/HomepageController.php with:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Game;
use App\Entity\User;
use App\Repository\GameRepository;
use App\Service\GameGridGenerator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class HomepageController extends AbstractController
{
    #[Route('/', name: 'app_homepage')]
    public function index(
        Request $request,
        GameRepository $gameRepository,
        GameGridGenerator $gridGenerator,
    ): Response {
        /** @var User|null $user */
        $user = $this->getUser();

        $game = null;

        if ($user) {
            $game = $gameRepository->findActiveForUser($user);
        } else {
            $gameId = $request->getSession()->get('current_game_id');
            if ($gameId) {
                $game = $gameRepository->find($gameId);
                if (!$game || $game->getStatus() !== Game::STATUS_IN_PROGRESS) {
                    $request->getSession()->remove('current_game_id');
                    $game = null;
                }
            }
        }

        if (!$game) {
            return $this->render('homepage/index.html.twig', [
                'game' => null,
            ]);
        }

        $gridData = $gridGenerator->computeGridData($game);

        return $this->render('homepage/index.html.twig', [
            'game' => $game,
            'grid' => $gridData['grid'],
            'width' => $gridData['width'],
            'middle' => $gridData['middle'],
        ]);
    }
}
  • Step 2: Commit
git add src/Controller/HomepageController.php
git commit -m "refactor: use persisted games in HomepageController"

Task 8: Update the homepage template

Files:

  • Modify: templates/homepage/index.html.twig

  • Modify: assets/styles/app.css (add game action bar styles)

  • Step 1: Update the template for 3 states

Replace the content of templates/homepage/index.html.twig with:

{% extends 'base.html.twig' %}

{% block body %}
    {% if game %}
        <div class="game-actions">
            <form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
                <input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
                <button type="submit" class="btn btn-abandon">Abandonner</button>
            </form>
        </div>

        <div {{ react_component('GameGrid', {
            grid: grid,
            width: width,
            middle: middle,
        }) }}></div>
    {% else %}
        <div class="game-start-container">
            <form method="post" action="{{ path('app_game_start') }}">
                <input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
                <button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
            </form>
        </div>
    {% endif %}
{% endblock %}
  • Step 2: Add CSS for game action bar and start button

Add the following at the end of assets/styles/app.css:

/* ── Game actions ── */

.game-actions {
    display: flex;
    justify-content: flex-end;
    padding: 12px 40px 0;
}

.btn-abandon {
    padding: 7px 16px;
    background: none;
    color: var(--text-muted);
    border: 1.5px solid var(--border);
    border-radius: 100px;
    font-family: 'Inter', sans-serif;
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    transition: border-color 0.15s, color 0.15s, background 0.15s;
}

.btn-abandon:hover {
    border-color: #dc2626;
    color: #dc2626;
    background: #fef2f2;
}

.game-start-container {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: calc(100vh - 64px - 80px);
}

.btn-start {
    padding: 14px 32px;
    font-size: 16px;
}
  • Step 3: Commit
git add templates/homepage/index.html.twig assets/styles/app.css
git commit -m "feat: update homepage template with start/abandon game UI"

Task 9: Manual verification

  • Step 1: Start the dev environment
make dev:up
  • Step 2: Run the migration
make db:migrate
  • Step 3: Test the flow as anonymous user
  1. Open http://localhost — should see "Commencer une partie" button
  2. Click the button — should see a game grid with "Abandonner" button above
  3. Refresh the page — the same grid should still be there
  4. Click "Abandonner" — should redirect to homepage with the "Commencer une partie" button
  • Step 4: Test the flow as connected user
  1. Log in
  2. Should see "Commencer une partie" button
  3. Start a game — grid appears with "Abandonner"
  4. Refresh — same grid persisted
  5. Abandon — back to "Commencer une partie"
  • Step 5: Verify database
docker compose exec app php bin/console doctrine:query:sql "SELECT id, user_id, main_actor_id, status, started_at, ended_at FROM game ORDER BY id DESC LIMIT 5"

Verify games have correct status (in_progressabandoned after abandon), timestamps, and user_id (null for anonymous, set for connected).

  • Step 6: Final commit (if any fixes needed)
git add -A
git commit -m "fix: address issues found during manual verification"