Skip to content

Repository files navigation

Battleship

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.

Quick start

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 test

Run with Docker

docker compose up --build

Then 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.

Architecture

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.

Layering decisions

  • 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. IRandomProvider with SeededRandomProvider and SystemRandomProvider), the interface gets its own file. This is just for simplicity in a small project.
  • Models + Services know nothing about HTTP or EF. Game and ShipPlacementHelper take IRandomProvider and an optional TimeProvider, so unit tests stay fast and deterministic.
  • Active games live in memory (InMemoryGameStore, ConcurrentDictionary). Each Game guards both mutations and reads with a lock: FireShot returns a fully-computed ShotResult and GetSnapshot() returns an immutable copy, so callers never enumerate the live shot/ship collections off-thread.
  • GameService owns 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 GameSummary row 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.db next to the API binary by default; overridable via the ConnectionStrings:BattleshipDb config key.
  • DateTimeOffset → ticks. SQLite cannot ORDER BY a DateTimeOffset, so BattleshipDbContext registers a ValueConverter<DateTimeOffset, long>. The app code still works with DateTimeOffset everywhere.

How randomness is testable

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.

Endpoints

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 gameId404 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/y on a shot → 400 (model validation; both are required)
  • Duplicate shot → 200 with alreadyShot: true and the prior result echoed back
  • Any unexpected error → 500 internal_error (logged server-side; no stack trace leaked)

Game rules

  • 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.

Tradeoffs and incomplete areas

  • 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, just fetch against 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 up run the API with a volume-backed SQLite file. There's no separate DB container since the we're using file-based SQLite for simplicity;

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages