Skip to main content

15.1 - Polish, Cross-play, Demo Preparation

Recap

14.1 - Web Game Flow gave the web app parallel games, per-player links, partial views, AI moves inside the request and a guard against the double POST. Functionally A6 is complete. This last week is about the things that decide whether the D3 demo goes smoothly: the console and the web app really sharing one database, errors that do not end in a stack trace, presets that exist on a fresh clone, the edge cases of your game, the README, the tag, and the script you will follow in front of the TA.

By the end of this lecture you should be able to:

  • Demonstrate cross-play — a game saved in console continues in web and back — and name everything that has to be identical for that to work.
  • Switch JSON ↔ DB in the web app with a configuration value and defend the design at the demo.
  • Turn exceptions and bad requests into friendly error pages, validate input on the server and log with ILogger<T>.
  • Seed presets on first run in both apps and handle the edge cases of your game (draws, passes, flying, unlock thresholds).
  • Prepare and run the D3 demo: tag d3, verify a fresh clone, follow the seven-item script and answer the typical questions.
Demo code

Lecture demos: csharp-2026-fall

Cross-play console ↔ web

Cross-play is not a feature you implement. It is what you get for free when two front-ends share the same libraries and the same storage — and what you lose the moment one detail differs.

What has to be identical

Must matchWhere it livesSymptom when it does not
SQLite file pathConnectionStrings:DefaultConnection in both appsettings.json filestwo databases, "my game is not in the list"
JSON directoryJsonDirectory in both appsettings.json filessame, in JSON mode
SchemaDAL.EF/Migrations, applied by Database.Migrate()"no such table" in one of the apps
IdsGuid Id on GameState, Name on GameConfigurationthe web cannot address a console game
Serialization of GameStateone JsonSerializerOptions in GameEngineenum stored as 1 by one app and "X" by the other, tuples lost
Meaning of the fieldsGameState in GameEngineconsole games have empty tokens — handled in lecture 14.1

The two appsettings.json files (in ConsoleUI and WebApp) carry the same three values:

{
"Persistence": "Db",
"ConnectionStrings": {
"DefaultConnection": "Data Source=~/.icd0008/games.db"
},
"JsonDirectory": "~/.icd0008/saves"
}

Both Program.cs files replace ~ with the user profile folder, as in lecture 65. The syllabus rule "no absolute local paths" is satisfied, and both apps land in the same directory on every machine.

One serializer for the game state

The JSON repository writes files, the EF repository writes the StateJson column. If they use different JsonSerializerOptions, a JSON file cannot be imported into the DB and — worse — two versions of your own code disagree about what "NextMoveBy": 1 means. Put the options in one place, next to the type they serialize:

// GameEngine/GameStateJson.cs
public static class GameStateJson
{
public static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
IncludeFields = true, // (Row, Col) tuples are fields, not properties
Converters = { new JsonStringEnumConverter() } // "X", not 1 — survives reordering the enum
};

public static string Serialize(GameState state) =>
JsonSerializer.Serialize(state, Options);

public static GameState Deserialize(string json) =>
JsonSerializer.Deserialize<GameState>(json, Options)
?? throw new InvalidDataException("Empty game state.");
}

GameRepositoryJson and GameRepositoryEf both call these two methods and nothing else. DateTime values are UTC (CreatedAtUtc, UpdatedAtUtc) — a DateTime.Now sneaking in on one side shows up as a two-hour jump in the game list.

Migrations from one place

The migrations live in DAL.EF. Both apps call Database.Migrate() at startup (it is idempotent; the second app finds nothing to apply), or you run dotnet ef database update once and document it in the README. What you must not do is EnsureCreated() in one app and Migrate() in the other — the first creates the schema without a migration history and the second then fails on "table already exists".

The round-trip test

Do this once before the demo, in this order, and again after every change to GameState:

  1. dotnet run --project ConsoleUI — new game from preset "Classic", three moves human vs human, save, exit.
  2. dotnet run --project WebApp — the game list shows the game with three moves; "Play as X"; make one move.
  3. Console again — load the game: four moves, the right player to move; make one move.
  4. Web — refresh: five moves, the console's move on the board.

The mechanical half of this is already covered by the A4 contract tests. One extra xUnit test pins the part that cross-play depends on — a state saved through one context loads identically through a fresh one:

[Fact]
public void Saved_game_round_trips_through_a_fresh_context()
{
using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options;
using (var setup = new AppDbContext(options)) setup.Database.EnsureCreated(); // fine in a test, never in an app

var game = GameBrain.NewGame(Presets.All[0]);
new GameBrain(game).MakeMove(0, 0);

using (var db = new AppDbContext(options)) new GameRepositoryEf(db).Save(game);

GameState? loaded;
using (var db = new AppDbContext(options)) loaded = new GameRepositoryEf(db).Get(game.Id);

Assert.NotNull(loaded);
Assert.Equal(game.Moves, loaded.Moves);
Assert.Equal(game.Board, loaded.Board);
Assert.Equal(EGamePiece.O, loaded.NextMoveBy);
}
info

Both apps open on the same SQLite file at the same time is fine. Writes are short, SQLite locks the file for milliseconds. If you ever see "database is locked", add ;Default Timeout=5 to the connection string — and check that you are not holding a DbContext in a static field.

JSON ↔ DB switch in web

Item 4 of the demo: "show the JSON ↔ DB switch (couple of lines or a config value); a JSON-saved console game loads in web". The switch is the if (persistence == "Json") block in Program.cs from lecture 13.1 — identical in both apps. Three ways to flip it without touching code:

dotnet run --project WebApp --Persistence=Json          # command line
Persistence=Json dotnet run --project WebApp # environment variable (macOS / Linux)

or appsettings.Development.json with "Persistence": "Json", which overrides appsettings.json only when running in Development. Command line beats environment beats appsettings.*.json beats appsettings.json — the same IConfiguration layering the console app uses.

For the demo: switch both apps to JSON, save a game in console, open the web list — the JSON file is there. Then switch both back. If only one app is switched, the game "disappears", which is a good thing to be able to explain: the switch selects the storage, not the data.

tip

The TA may ask you to flip the switch live. Know which file, which line, and what the two allowed values are. Thirty seconds, no searching.

Server-side validation and friendly errors

Validation

Two layers, both on the server. Data annotations on the input DTO catch shape errors ([Required], [Range], [StringLength]) and are reported by the validation tag helpers. Rule-level errors — win length larger than the board, an even board size for Reversi, a grid larger than the board in Tic-Tac-Two — belong in the engine, because the console needs the same checks:

// GameEngine/ConfigValidator.cs
public static class ConfigValidator
{
public static IEnumerable<string> Validate(GameConfiguration config)
{
if (config.WinLength > Math.Max(config.BoardWidth, config.BoardHeight))
yield return "Win length does not fit on the board.";
if (config.BoardWidth * config.BoardHeight < 2 * config.WinLength)
yield return "Board is too small for a meaningful game.";
}
}

// WebApp/Pages/Configs/Create.cshtml.cs
public IActionResult OnPost()
{
var config = Input.ToConfiguration();
foreach (var error in ConfigValidator.Validate(config))
{
ModelState.AddModelError(string.Empty, error);
}
if (!ModelState.IsValid) return Page();

configs.Save(config);
return RedirectToPage("./Index");
}

Illegal moves are not errors either: MakeMove returns false, the page sets TempData["Error"] and redirects. A user can never produce a 500 by clicking.

Error pages

What remains are real exceptions (a corrupted JSON file, a bug) and status codes without a body (NotFound(), a route that did not match). Two middlewares in Program.cs:

if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
}
app.UseStatusCodePagesWithReExecute("/Error/{0}");

UseExceptionHandler catches exceptions and re-executes the pipeline for /Error, so the user sees a page and the response is a 500. UseStatusCodePagesWithReExecute does the same for 400–599 responses that have no body — your return NotFound(), the 404 of a non-Guid id — with the code in the route. In Development the developer exception page stays on, because you want the stack trace.

@page "{code:int?}"
@model WebApp.Pages.ErrorModel
@{ ViewData["Title"] = Model.Title; }

<h1>@Model.Title</h1>
<p>@Model.Message</p>
<p><a asp-page="/Games/Index">Back to the games</a></p>
@if (Model.RequestId is not null)
{
<p class="text-muted"><small>Request id: <code>@Model.RequestId</code></small></p>
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public int? Code { get; private set; }
public string? RequestId { get; private set; }

public string Title => Code switch
{
404 => "Not found",
400 => "Bad request",
_ => "Something went wrong"
};

public string Message => Code switch
{
404 => "That game or page does not exist. It may have been deleted.",
400 => "The request did not make sense to the server.",
_ => "The error has been logged. Go back to the list and try again."
};

public void OnGet(int? code)
{
Code = code;
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}

The template's Error.cshtml is the starting point; the {code:int?} route and the two switch expressions are the addition. A friendly 404 for a deleted game is the difference between "polished" and "it crashed when the TA typed a URL".

Logging

ILogger<T> is already in the container; inject it like any other service. Use message templates with named placeholders — not string interpolation — so the values stay structured:

public class PlayModel(IGameRepository games, ILogger<PlayModel> logger) : PageModel
{
// ... inside OnPostAsync
if (!brain.MakeMove(row, col))
{
logger.LogWarning("Game {GameId}: rejected move {Row},{Col} by {Piece}", game.Id, row, col, me);
TempData["Error"] = $"Illegal move ({row}, {col}).";
return RedirectToPage(new { gameId, token });
}
logger.LogInformation("Game {GameId}: {Piece} played {Row},{Col}", game.Id, me, row, col);

Levels are configured in appsettings.json. Turning on EF Core's command logging shows every SQL statement in the console — useful once, noisy after that:

"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}

The console app gets the same ILogger<T> if its host from lecture 08.1 called AddLogging — the repositories can log in both apps with no extra code. Log the AI's search time and depth at Information; it is the evidence for "AI answers within its time budget" at the demo.

Seeding presets on first run

Item 2 of the demo starts with "create a game from a preset". On a fresh clone the database is empty, so the presets must appear on first start — in both apps, in both persistence modes. The presets belong to the game, so they live in GameEngine; the seeding goes through the repository interface, so it works for JSON and DB alike:

// GameEngine/Presets.cs
public static class Presets
{
public static IReadOnlyList<GameConfiguration> All { get; } =
[
new("Classic", 7, 6, 4), // your record has more fields — cylinder, walls, unlock threshold...
new("Connect3", 5, 4, 3),
new("Connect5", 9, 7, 5),
];

public static void EnsureSeeded(IConfigRepository configs)
{
var existing = configs.List().Select(c => c.Name).ToHashSet();
foreach (var preset in All.Where(p => !existing.Contains(p.Name)))
{
configs.Save(preset);
}
}
}
// WebApp/Program.cs, after Build()
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
if (persistence == "Db")
{
services.GetRequiredService<AppDbContext>().Database.Migrate();
}
Presets.EnsureSeeded(services.GetRequiredService<IConfigRepository>());
}

The console Program.cs has the same two lines after BuildServiceProvider(). EF Core's HasData would seed only the database, not the JSON directory, and it bakes the data into migrations — a change to a preset would need a new migration. The repository version is ten lines and mode-independent.

Edge cases

A rule that only exists in the UI is a bug waiting for the other UI. The engine owns every rule below; the UI only renders what the engine says.

GameEdge caseEngineWhat the web shows
allDraw — board full or nobody can move, no winnerIsDraw() true, MakeMove refuses further moves"Draw." in _Status, no clickable cells
Connect FourCylinder — a line crosses the side edgeCheckWin wraps the column index modulo widththe wrapped cells highlighted on both edges
Tic-Tac-TwoUnlock threshold — grid or piece moves before N pieces are placedCanMoveGrid() / CanMovePiece() false until the thresholdgrid buttons and piece links not rendered
ReversiPass — the player to move has no legal moveGetLegalMoves() empty, MakeMove hands the turn back (or an explicit Pass()); two passes in a row end the game"O has no legal move — X plays again"
GomokuOverline — six in a row under the exact-five ruleCheckWin compares the run length exactly when the toggle is onnothing special — but test it
Nine Men's MorrisFlying — three pieces left may move anywhere; removing from a mill when no free piece existsGetLegalMoves returns every empty point; the removal toggleevery empty point becomes a destination

Each row is one [Fact] in Tests: Full_board_without_winner_is_draw, Player_without_legal_moves_passes, Win_across_cylinder_edge_is_detected, Grid_cannot_move_before_unlock_threshold, With_three_pieces_every_empty_point_is_a_destination. These are the tests the TA will ask to see for item 6 of the demo — and the ones that let you show the extension live from a prepared position instead of playing forty moves to reach it.

Light web tests with WebApplicationFactory

Optional, twenty lines, and a nice answer to "did you test the web layer at all". Microsoft.AspNetCore.Mvc.Testing boots the real Program.cs in memory and gives you an HttpClient.

dotnet add Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add Tests reference WebApp

Program.cs with top-level statements generates an inaccessible class; make it visible by adding one line at the end of WebApp/Program.cs:

public partial class Program { }
public class SmokeTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;

public SmokeTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("Persistence", "Json");
builder.UseSetting("JsonDirectory", Path.Combine(Path.GetTempPath(), "icd0008-tests"));
}).CreateClient();
}

[Theory]
[InlineData("/")]
[InlineData("/Configs")]
[InlineData("/Games")]
public async Task Page_returns_200(string url)
{
var response = await _client.GetAsync(url);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Fact]
public async Task Unknown_game_id_returns_404()
{
var response = await _client.GetAsync($"/Games/Play/{Guid.NewGuid()}");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

UseSetting overrides configuration before the app starts, so the tests never touch your real ~/.icd0008 data — and this is the second place where the persistence switch pays for itself.

README expectations

The README is read by the TA before your slot and by you in a year. Required by the syllabus, in this order:

# Firstname Lastname — ICD0008 — <Assigned game>
Student code, uni-id, school email

## Run
dotnet run --project ConsoleUI
dotnet run --project WebApp → open the URL printed by Kestrel

## Data
~/.icd0008/games.db (SQLite) and ~/.icd0008/saves (JSON)
Switch with "Persistence": "Db" | "Json" in appsettings.json of both apps

## Rules and extensions
Which extensions are implemented, how to reach them (which preset, which config value)

## AI
Difficulty levels — depth / budget — typical time per move on your laptop (a small table)

## Tests
dotnet test — what is covered (rules, extensions, repository contract, AI, web smoke)

## AI usage
See AI_USAGE.md

AI_USAGE.md is the log the AI policy asks for: the prompts or specs you used, what the AI got wrong and how you found out, what you changed by hand, and the alternatives you considered and rejected. A dozen honest entries beat a page of ceremony. It is also your best preparation for the "explain this line" questions — if the log says why, you remember why.

Tag d3 and verify the fresh clone

Code freeze is Thursday 17.12 at 23:59:59. A tag gives you the fallback the syllabus allows if the frozen commit does not build on defense day:

git tag -a d3 -m "D3 submission: web app + demo"
git push origin d3

Then do exactly what the TA does, on your own laptop, in a directory that has never seen your code:

git clone <repository-url> /tmp/d3-check     # the HTTPS URL of your course repository
cd /tmp/d3-check
dotnet build
dotnet test
dotnet run --project WebApp

If any of these fails, the clone is what is wrong — a file that is ignored by .gitignore but needed by the build, a hard-coded path, a migration you forgot to commit. Fix, commit, move the tag (git tag -f d3 && git push -f origin d3), clone again.

warning

The frozen commit is graded, whichever Friday you defend on. Commits after the freeze do not count; the tag is only a fallback for "does not compile".

D3 demo script

Twenty to thirty minutes, no slides, running software. The seven items are the syllabus's "D3 Full Application Demo Requirements", in the order you should show them:

#RequirementWhat you doTime
1Fresh clone builds, tests greenTerminal in a clone made this morning: dotnet build, dotnet test. Green output visible.2 min
2Console: preset and custom config, human vs human, save, exitStart console. Create config "Demo" with a non-default win length. Start a game from preset "Classic", play four moves hot-seat, save, exit.3 min
3Web: list, continue the console game, human vs human in two windowsStart web. The list shows the console game. "Play as X" in a normal window, the O link in a private window. One move each; show the waiting window refresh by itself.4 min
4JSON ↔ DB switch; a JSON console game loads in webShow the "Persistence" value. Switch both apps to JSON, save a game in console, it appears in the web list. Switch back.3 min
5Human vs AI and AI vs AI in both apps, difficulty selection, time budgetConsole: human vs AI at level 3, then AI vs AI. Web: create human vs AI, the AI answers inside the POST; AI vs AI with auto-play. Point at the log line with the search time.4 min
6One mandatory extension liveReach the position (prepared save or a short sequence) and show the rule: the win across the cylinder edge, the wall, the flying move, the unlock threshold. Show its test.2 min
7Tests, migrations, repository interfaces, DI registrationRider: Tests project, DAL.EF/Migrations, IGameRepository and IConfigRepository in GameEngine, both Program.cs files side by side.3 min

Preparation the evening before: presets seeded, a half-played console game saved, both browser windows bookmarked, Rider open on the right files, terminal history ready. Nothing is typed from memory except moves.

Rules of engagement: the TA may ask for a scenario in a different order, may ask you to change the win length or flip the persistence switch live, and will open your Pages/ folder looking for game logic. Say "I don't know" rather than guess — a wrong confident answer costs more than a gap.

Typical D3 questions

  • Where is the game logic? — Only in GameEngine. Offer to search Pages/ for Board[ to prove it.
  • Why is DbContext scoped? — Not thread-safe, tracks entities; one per request is the unit of work. Singleton would share it across requests, transient would split it.
  • How does the web app know whose turn it is? — The token in the link resolves to X, O or spectator; the POST handler accepts a move only if the resolved piece equals NextMoveBy.
  • Two players click at the same time — what happens? — Both requests load the same state; the expected move count rejects the stale one, and the EF concurrency token on UpdatedAtUtc catches the interleaving between load and save.
  • What has to be equal for console → web cross-play? — File path, schema (migrations), ids, and the JsonSerializerOptions used for GameState.
  • Why does the AI not freeze the web app? — It runs inside the request with a CancellationToken budget and returns its best move so far; the user waits at most the budget. The console uses the same provider.
  • Why is the game id in the URL? — HTTP is stateless; the server keeps no session per browser, so every request must say which game it is about.
  • What breaks if you rename a property on GameState? — Every saved game, JSON or DB, stops deserializing that field. Keep names stable or add a [JsonPropertyName] — persistence is a contract.
  • Why Guid and not an int identity? — The engine creates the id without a database round-trip, so JSON and DB agree and a game can be created offline in either app.
  • Change the win length of the Classic preset to 5, live. — Know that it is in Presets.All and that EnsureSeeded will not overwrite an existing row — you either delete the row or edit the config in the web UI.

Optional: Dockerfile for the web app

Bonus territory, fifteen lines. The syllabus says everything runs on your laptop; a container is a nice extra, not a requirement.

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish WebApp/WebApp.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ConnectionStrings__DefaultConnection="Data Source=/data/games.db"
ENV JsonDirectory=/data/saves
VOLUME /data
EXPOSE 8080
ENTRYPOINT ["dotnet", "WebApp.dll"]
docker build -t icd0008-web .
docker run --rm -p 8080:8080 -v icd0008-data:/data icd0008-web

Three details: the double underscore in ConnectionStrings__DefaultConnection is how environment variables spell the : of configuration keys; the path is absolute so the ~ replacement is a no-op; and /data is a named volume, otherwise the database dies with the container. The console app on your laptop cannot see a database inside a container — the cross-play demo stays outside Docker.

What continues in the next course

Everything in this course was built so that the spring course Web Applications with C# (ICD0024) can start without a recap. It assumes EF Core, DI, repositories, Razor syntax and tag helpers, and adds:

  • MVC — controllers, views and view models as the second UI model of ASP.NET Core, next to Razor Pages.
  • ASP.NET Core Identity — the real login: users, roles, password hashing, cookies and JWT. The DIY token scheme from lecture 14.1 gets replaced, the whose-turn logic stays.
  • REST API — controllers returning DTOs, versioning, OpenAPI; a JavaScript front-end from ICD0006 talks to it.
  • Clean architecture — Domain, BLL, DAL and Web as separate layers with mapped DTOs at each boundary, generic repositories and a unit of work; the JSON/EF swap you did here is the small version of the same idea.

Self preparation QA

  1. What must be identical between console and web for cross-play to work? — The storage location (connection string or JSON directory), the schema applied by the same migrations, the ids, and the one JsonSerializerOptions used to serialize GameState.
  2. Why do both repositories call the same GameStateJson.Serialize? — So a state written by one implementation deserializes in the other; enums as strings and IncludeFields for tuples are decided once.
  3. How do you flip the web app from DB to JSON without editing code? — Set Persistence to Json via command line, environment variable or appsettings.Development.json; the if in Program.cs registers the JSON repositories.
  4. What is the difference between UseExceptionHandler and UseStatusCodePagesWithReExecute? — The first handles thrown exceptions (500) by re-executing /Error; the second gives bodyless 400–599 responses such as NotFound() a page, with the code in the route.
  5. Why seed presets through IConfigRepository instead of EF HasData? — It works for both persistence modes, does not bake data into migrations, and runs identically from the console and the web Program.cs.
  6. Where does a pass move in Reversi or flying in Nine Men's Morris belong, and why? — In GameBrain (GetLegalMoves / MakeMove), so that console, web and the AI all see the same rule without any UI-side if.
  7. What does WebApplicationFactory give you and what one line does it need in Program.cs? — An in-memory host of the real app with an HttpClient; public partial class Program { } at the end of Program.cs so the tests can reference the entry class.
  8. Why tag d3 and verify a fresh clone? — The tag is the fallback the syllabus allows if the frozen commit does not compile; the fresh clone reveals ignored files, hard-coded paths and uncommitted migrations before the TA does.