docs: add game grid persistence design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
docs/superpowers/specs/2026-03-30-game-persistence-design.md
Normal file
108
docs/superpowers/specs/2026-03-30-game-persistence-design.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Game Grid Persistence
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Game grids are currently generated on the fly at every page load in `HomepageController`. There is no persistence — refreshing the page gives a new grid. Players cannot resume a game.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Persist game grids so players can resume their current game. Display a "Start a game" button when no game is in progress. Allow players to abandon a game.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Grid generation and persistence only
|
||||||
|
- No win/loss logic (will be added later)
|
||||||
|
- No saving of player input (filled letters)
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Entity: `Game`
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------------|-------------------------|------------------------------------------------|
|
||||||
|
| `id` | int (auto) | Primary key |
|
||||||
|
| `user` | ManyToOne → User, nullable | Null for anonymous players |
|
||||||
|
| `mainActor` | ManyToOne → Actor | The actor to guess |
|
||||||
|
| `status` | string | `in_progress` or `abandoned` (extensible to `won` later) |
|
||||||
|
| `startedAt` | DateTimeImmutable | When the game started |
|
||||||
|
| `endedAt` | DateTimeImmutable, nullable | When the game ended (abandon or win) |
|
||||||
|
|
||||||
|
### Entity: `GameRow`
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|------------|--------------------|------------------------------------------------|
|
||||||
|
| `id` | int (auto) | Primary key |
|
||||||
|
| `game` | ManyToOne → Game | Parent game |
|
||||||
|
| `actor` | ManyToOne → Actor | The actor for this row |
|
||||||
|
| `position` | int | Index of the target letter in the actor's name |
|
||||||
|
| `rowOrder` | int | Order of this row in the grid |
|
||||||
|
|
||||||
|
`GameRow` is a separate entity (not JSON) to allow attaching hints/clues per row in a future iteration.
|
||||||
|
|
||||||
|
## Service: `GameGridGenerator`
|
||||||
|
|
||||||
|
Extracted from the current `HomepageController::index` logic:
|
||||||
|
|
||||||
|
1. Select a random main actor via `ActorRepository::findOneRandom()`
|
||||||
|
2. For each letter (a-z) in the main actor's name, find a random actor containing that letter
|
||||||
|
3. Create a `Game` entity with `status = in_progress` and `startedAt = now`
|
||||||
|
4. Create `GameRow` entities with the actor, letter position, and row order
|
||||||
|
5. Persist and flush
|
||||||
|
6. Return the `Game` entity
|
||||||
|
|
||||||
|
Also provides a method to compute grid display data (width, middle) from a `Game` and its rows, for passing to the React component.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### `GET /` — Homepage (modified)
|
||||||
|
|
||||||
|
Current behavior replaced:
|
||||||
|
|
||||||
|
1. If user is connected → query `GameRepository` for a `Game` with `status = in_progress` for this user
|
||||||
|
2. If anonymous → get `current_game_id` from the Symfony session, then look up the `Game` in DB
|
||||||
|
3. If a game is found and still `in_progress` → render the grid with an "Abandon" button above it
|
||||||
|
4. If no game is found → render a "Start a game" button
|
||||||
|
|
||||||
|
### `POST /game/start` — Start a new game (new)
|
||||||
|
|
||||||
|
1. Verify no game is already in progress for this player (connected or session)
|
||||||
|
2. Call `GameGridGenerator` to create the game
|
||||||
|
3. If user is connected → the game is linked to the user
|
||||||
|
4. If anonymous → store `$game->getId()` in session key `current_game_id`
|
||||||
|
5. Redirect to `/`
|
||||||
|
|
||||||
|
### `POST /game/{id}/abandon` — Abandon a game (new)
|
||||||
|
|
||||||
|
1. Verify the game belongs to the current player (user match or session match)
|
||||||
|
2. Set `status = abandoned` and `endedAt = now`
|
||||||
|
3. If anonymous → remove `current_game_id` from session
|
||||||
|
4. Redirect to `/`
|
||||||
|
|
||||||
|
Both POST routes use CSRF protection.
|
||||||
|
|
||||||
|
## Anonymous Session Handling
|
||||||
|
|
||||||
|
- Session key: `current_game_id` (stores the `Game` ID)
|
||||||
|
- Set on game creation, removed on abandon
|
||||||
|
- On homepage load: if the stored game ID no longer exists or is not `in_progress`, clean up the session key
|
||||||
|
- No migration of anonymous games on login — the anonymous game is lost when the user logs in. Acceptable since we don't save letter input.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Template changes (`homepage/index.html.twig`)
|
||||||
|
|
||||||
|
Three states:
|
||||||
|
|
||||||
|
1. **No game in progress**: centered "Start a game" button (Twig form, POST to `/game/start`)
|
||||||
|
2. **Game in progress**: action bar above the grid with an "Abandon" button (Twig form, POST to `/game/{id}/abandon` with CSRF token), then the React `GameGrid` component below
|
||||||
|
3. **After abandon**: redirect to `/`, which shows state 1
|
||||||
|
|
||||||
|
### React components
|
||||||
|
|
||||||
|
No changes to `GameGrid`, `GameRow`, `LetterInput`, or `ActorPopover`. They receive the same props (`grid`, `width`, `middle`) — the data source changes from on-the-fly generation to database lookup.
|
||||||
|
|
||||||
|
## Future Extensibility
|
||||||
|
|
||||||
|
- **Win logic**: add `won` to the status enum, set `endedAt` on win
|
||||||
|
- **Hints/clues per row**: add a relation on `GameRow` (e.g., `GameRowHint` entity)
|
||||||
|
- **Game history/stats**: query `Game` entities by user with status filters
|
||||||
Reference in New Issue
Block a user