A Battleship game with all logic on the server (ASP.NET Core Web API) and a thin static client served from the same app. Game summaries are persisted with EF Core + SQLite.
Prerequisites: .NET 8 SDK. (No Node/npm - the frontend is plain HTML/CSS/JS served from wwwroot)
# Run app
dotnet run --project src/Battleship
# Open <http://localhost:5272> to play
# Swagger is at <http://localhost:5272/swagger> in dev# Run tests
dotnet testdocker compose up --buildThen open http://localhost:8080 (Swagger at http://localhost:8080/swagger).
The SQLite database lives on a named volume (battleship-data), so completed-game
summaries survive docker compose down. Use docker compose down -v to also drop
the data volume.
Single ASP.NET Core project, organized by responsibility with one public type per file:
src/Battleship/
Program.cs DI, EF Core registration, static files, middleware
AppJson.cs Shared JSON contract (camelCase + string enums)
Controllers/ GamesController, SummariesController
Contracts/ API DTOs only (no behavior)
Exceptions/ Domain exceptions
Models/ Domain types with behavior/invariants — no ASP.NET, no EF
Enums/
Helpers/ Stateless domain logic
Services/ Behavior — no ASP.NET, no EF
Data/ EF Core
Middleware/
wwwroot/ Thin static client (index.html, app.js, styles.css)
tests/Battleship.Tests/ NUnit, mirrors the source folders
Models/
Helpers/
Data/
Integration/
DTOs live in Battleship.Contracts, separate from the behavior-bearing domain
types in Battleship.Models, so the domain never depends on transport shapes.
The dependency direction is one-way: Controllers → Services/Contracts → Models,
and Data → Models.
- Interfaces are colocated with their implementation when there's only one. If a
type has a single concrete implementation (e.g.
ISummaryRepository/SummaryRepository,IGameService/GameService), the interface lives in the same file as the class. When multiple implementations exist (e.g.IRandomProviderwithSeededRandomProviderandSystemRandomProvider), the interface gets its own file. This is just for simplicity in a small project. - Models + Services know nothing about HTTP or EF.
GameandShipPlacementHelpertakeIRandomProviderand an optionalTimeProvider, so unit tests stay fast and deterministic. - Active games live in memory (
InMemoryGameStore,ConcurrentDictionary). EachGameguards both mutations and reads with a lock:FireShotreturns a fully-computedShotResultandGetSnapshot()returns an immutable copy, so callers never enumerate the live shot/ship collections off-thread. GameServiceowns orchestration. Controllers only map DTOs; create, fire, and "persist on win" live in the service. The winning shot is detected inside the game lock (ShotResult.JustWon), so a summary is written exactly once.- Only completed games persist. EF Core writes a single
GameSummaryrow when the final shot wins the game; in-flight games never touch the DB. - SQLite for persistence. Zero-config, single file, full EF Core provider.
Stored as
battleship.dbnext to the API binary by default; overridable via theConnectionStrings:BattleshipDbconfig key. - DateTimeOffset → ticks. SQLite cannot
ORDER BYaDateTimeOffset, soBattleshipDbContextregisters aValueConverter<DateTimeOffset, long>. The app code still works withDateTimeOffseteverywhere.
IRandomProvider wraps System.Random. SeededRandomProvider(int seed) gives
deterministic placement; SystemRandomProvider (backed by the thread-safe
Random.Shared) is used when no seed is supplied. RandomProviderFactory
picks between them. POST /games exposes an optional seed field so test runs
and the integration suite can reproduce a specific board. Placement tests assert
identical layouts for the same seed and different layouts for different seeds.
| Method + path | Body | Notes |
|---|---|---|
POST /games |
{boardSize?, fleet?, noAdjacency?, seed?} |
Returns {gameId, boardSize, shipCount, noAdjacency}. Ship positions are not exposed. |
GET /games/{id} |
Public state (shots, sunk ships). Reveals ship positions only after isWon == true. |
|
POST /games/{id}/shots |
{x, y} |
Returns outcome, ship sunk (if any), shots fired, ships remaining, won flag, and alreadyShot for idempotent re-fires. |
GET /summaries |
?take=50 |
Leaderboard sorted by fewest shots, then earliest completion. |
All errors are returned as RFC 7807 ProblemDetails (title = machine code, detail = message):
- Unknown
gameId→404 game_not_found - Out-of-bounds shot →
400 out_of_bounds - Shot after a game is won →
400 game_already_won - Invalid create options (empty fleet, ship shorter than 1 cell, board smaller than the longest ship, fleet too dense to place) →
400 invalid_game_options - Missing
x/yon a shot →400(model validation; both are required) - Duplicate shot →
200withalreadyShot: trueand the prior result echoed back - Any unexpected error →
500 internal_error(logged server-side; no stack trace leaked)
- Default board: 10×10 with the standard fleet (Carrier 5, Battleship 4, Cruiser 3, Submarine 3, Destroyer 2).
- Configurable: any board size that fits the fleet (at least as large as the longest ship) and any fleet that fits.
- Optional "no adjacency" rule which enforces that ships cannot touch, even diagonally.
- Ships are placed straight (no diagonals) by retrying random
(orientation, x, y)triples until a non-conflicting placement is found. After 500 failed attempts for a single ship the placer throws, which surfaces fleets that don't fit the board.
- Frontend is intentionally minimal. A single static page
(
wwwroot/index.html+app.js) served by the API — no framework, no build step, no Node toolchain, justfetchagainst the REST API. Served same-origin, so no CORS configuration is needed. - No durable game state. Active games are in-process only. A restart loses in-flight games.
- Simplistic Docker setup.
Dockerfile+docker compose uprun the API with a volume-backed SQLite file. There's no separate DB container since the we're using file-based SQLite for simplicity;