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:
-
gametable with columns:id,user_id(nullable FK → user),main_actor_id(FK → actor),status,started_at,ended_at(nullable) -
game_rowtable 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/gameroutes 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
/gameroutes 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
- Open
http://localhost— should see "Commencer une partie" button - Click the button — should see a game grid with "Abandonner" button above
- Refresh the page — the same grid should still be there
- Click "Abandonner" — should redirect to homepage with the "Commencer une partie" button
- Step 4: Test the flow as connected user
- Log in
- Should see "Commencer une partie" button
- Start a game — grid appears with "Abandonner"
- Refresh — same grid persisted
- 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_progress → abandoned 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"