# 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 */ #[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 */ 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** ```bash 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 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** ```bash 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 */ 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 */ class GameRowRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, GameRow::class); } } ``` - [ ] **Step 3: Commit** ```bash 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** ```bash 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** ```bash make db:migrate ``` Expected: Migration executes successfully. - [ ] **Step 4: Commit** ```bash 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 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, 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** ```bash 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 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): ```yaml 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** ```bash 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 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** ```bash 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: ```twig {% extends 'base.html.twig' %} {% block body %} {% if game %}
{% else %}
{% endif %} {% endblock %} ``` - [ ] **Step 2: Add CSS for game action bar and start button** Add the following at the end of `assets/styles/app.css`: ```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** ```bash 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** ```bash make dev:up ``` - [ ] **Step 2: Run the migration** ```bash 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** ```bash 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_progress` → `abandoned` after abandon), timestamps, and user_id (null for anonymous, set for connected). - [ ] **Step 6: Final commit (if any fixes needed)** ```bash git add -A git commit -m "fix: address issues found during manual verification" ```