An ASP.NET Core Web API that hosts Conway's Game of Life. Boards are persisted to disk so the service can crash and restart without losing state.
| Method | Path | Description |
|---|---|---|
POST |
/api/boards |
Upload a new board state. Returns { id }. |
GET |
/api/boards/{id} |
Get the originally uploaded board (generation 0). |
GET |
/api/boards/{id}/next |
Get the next state (generation 1). |
GET |
/api/boards/{id}/states/{steps} |
Get the state steps generations from the initial state. |
GET |
/api/boards/{id}/final?maxAttempts=N |
Get the final state. Returns 422 Unprocessable Entity if the board does not reach a stable or cyclic conclusion within N generations (defaults to 1000). |
Boards are uploaded as a 2D bool[][], where true means alive:
POST /api/boards
{
"cells": [
[false, false, false, false, false],
[false, false, false, false, false],
[false, true, true, true, false],
[false, false, false, false, false],
[false, false, false, false, false]
]
}The grid is finite and bounded (cells outside the grid are dead). Final-state
queries detect both stable boards (period 1) and cyclic boards
(any period > 1) — on a finite grid every Game-of-Life run eventually cycles,
so reaching maxAttempts without finding one means we just need a larger budget.
GameOfLife.Api/
├── Controllers/
│ └── BoardsController.cs <- the four required endpoints
├── Data/
│ ├── AppDbContext.cs <- EF Core DbContext
│ └── BoardEntity.cs <- persisted record
├── Dtos/
│ └── Dtos.cs <- request/response shapes
├── Services/
│ ├── GameOfLifeEngine.cs <- pure Game-of-Life rules + cycle detection
│ ├── GameOfLifeService.cs <- glue between persistence and the engine
│ └── BoardSerialization.cs <- bool[,] <-> bool[][] <-> packed-bits
└── Program.cs <- DI/wiring
Three layers, all dependency-injected:
IGameOfLifeEngine— pure functions forNextGeneration,Advance(N), andComputeFinalState. No I/O, deterministic, trivially unit-testable.IGameOfLifeService— talks to the database via EF Core, dispatches computation to the engine.BoardsController— translates HTTP <-> service calls and validates input.
Boards are stored in SQLite via EF Core. We persist only the initial uploaded state — every "next/Nth/final" query is computed deterministically from that initial state on demand. This means:
- All read endpoints are idempotent (no hidden mutation between calls).
- The state model is trivially safe across restarts: there's nothing to reconcile, just a row per uploaded board.
- Swapping the provider for Postgres / SQL Server is a one-line change in
Program.cs; the rest of the code is provider-agnostic.
Database.EnsureCreated() runs at startup so the schema bootstraps itself
the first time the API is launched. For a real production deployment you
would replace this with dotnet ef migrations and apply them explicitly.
ComputeFinalState runs the simulation step-by-step, hashing each generation
(packed bits, base64) and storing hash -> generation in a Dictionary. As
soon as a generation's hash is found in the dictionary we know we've entered
a cycle — the cycle period is currentGeneration - firstSeenAt:
- period == 1 -> Stable (board no longer changes; this includes the all-dead case)
- period > 1 -> Cyclic (oscillator/spaceship behaviour)
If maxAttempts is exhausted without revisiting any state, we return
DidNotTerminate, which the controller maps to HTTP 422.
Requirements: .NET 8 SDK.
dotnet run --project src/GameOfLife.ApiThe API listens on http://localhost:5xxx (the launch port chosen by Kestrel).
Swagger UI is available at /swagger in Development.
A SQLite database file gameoflife.db is created next to the API binary on
first launch. Stop and restart the process — uploaded boards will still be
there.
# 1) create a horizontal blinker
ID=$(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"cells":[[false,false,false,false,false],[false,false,false,false,false],[false,true,true,true,false],[false,false,false,false,false],[false,false,false,false,false]]}' \
http://localhost:5099/api/boards | jq -r .id)
# 2) next state (vertical blinker)
curl http://localhost:5099/api/boards/$ID/next
# 3) state 5 generations away
curl http://localhost:5099/api/boards/$ID/states/5
# 4) final state (period-2 cycle)
curl "http://localhost:5099/api/boards/$ID/final?maxAttempts=20"dotnet testdotnet test runs 32 tests across three test classes:
Each of the four Game-of-Life rules is tested on its own:
- Rule 1 — live cells with < 2 neighbors die (underpopulation)
- Rule 2 — live cells with 2 or 3 neighbors survive
- Rule 3 — live cells with > 3 neighbors die (overpopulation)
- Rule 4 — dead cells with exactly 3 neighbors come alive (reproduction)
Plus the canonical patterns from the Game-of-Life literature:
- The block is a still life (period 1).
- The blinker oscillates with period 2 (horizontal <-> vertical).
- The toad oscillates with period 2.
- The glider returns to its original shape, shifted one cell down-right, every 4 generations.
- An empty board stays empty.
- A diagonal pair (no cell has > 1 neighbor) dies in 1 step → final state is empty.
ComputeFinalState is tested for stable, cyclic, and DidNotTerminate
outcomes (the last using the famously chaotic R-pentomino with a deliberately
low maxAttempts).
Spins up the real ASP.NET Core pipeline via WebApplicationFactory<Program>
and exercises every endpoint:
POST /api/boardsreturns 201 with an id, rejects ragged/empty input with 400.GET .../nextreturns the next generation, 404 for unknown ids.GET .../states/{n}returns the Nth generation; rejects negative N with 400.GET .../finalreturns Stable for the block, Cyclic+period-2 for the blinker, 422 for the R-pentomino with a lowmaxAttempts, 404 for unknown ids.
The most important test for the "should be able to restart/crash and retain state" requirement. It:
- Spins up an API instance pointing at a temp SQLite file, creates a board.
- Fully disposes the host —
WebApplicationFactory<Program>shuts down the entire ASP.NET Core pipeline. - Spins up a brand-new API instance pointing at the same file.
- Verifies that
GET /boards/{id},GET /boards/{id}/next, andGET /boards/{id}/finalall still work for the original id and produce the same answers.
For an OS-level demonstration you can also kill -9 the running process and
restart it with dotnet run — the same board ids continue to work, because
they're stored in gameoflife.db on disk.