14.1 - Web Game Flow: Parallel Games and Players
Recap
In 13.1 - Razor Pages + EF Core + DI the web app got the same repositories as the console app and a Play page that loads a GameState, applies one move through GameBrain, saves and redirects. That page handled one game in one browser. This week: many games at once, two players in two browsers, an AI answering inside the request, and the race that appears the moment two people click at the same time.
By the end of this lecture you should be able to:
- Run an unlimited number of parallel games, each addressed by its own
Guidin the URL, with a list page to create, join, continue and delete games. - Hand each player a secret link with a token, enforce whose turn it is on the server, and explain the cookie and per-game-password alternatives.
- Split the board and status into partial views and refresh the waiting player's browser automatically.
- Run an AI move right after the human's POST within the time budget, and step AI-vs-AI games.
- Recognise the double-POST race and guard against it with an expected move count or an EF Core concurrency token.
Lecture demos: csharp-2026-fall
One URL per game
The web server remembers nothing between requests, so "which game" has to be in every request. It is: @page "{gameId:guid}" puts the id into the path, /Games/Play/3f2a9c…. Every game is one row in SavedGame (or one file in the JSON directory), every row has its own Guid, and two browsers on two different URLs never touch each other's state. That is all "unlimited parallel games" means — there is no game manager object, no in-memory dictionary of running games, nothing to run out of.
What the syllabus asks for on top of that is a way to get to those URLs: a list, a create page, and a way to hand the second URL to the second player.
The game list: Pages/Games/Index
public class IndexModel(IGameRepository games) : PageModel
{
public List<GameState> Games { get; private set; } = new();
public void OnGet() =>
Games = games.List().OrderByDescending(g => g.CreatedAtUtc).ToList();
public IActionResult OnPostDelete(Guid gameId)
{
games.Delete(gameId);
return RedirectToPage();
}
}
@page
@using GameEngine
@model WebApp.Pages.Games.IndexModel
@{ ViewData["Title"] = "Games"; }
<h1>Games</h1>
<p><a asp-page="./Create" class="btn btn-primary">New game</a></p>
<table class="table">
<thead>
<tr><th>Configuration</th><th>Players</th><th>Moves</th><th>Next</th><th>Created</th><th></th></tr>
</thead>
<tbody>
@foreach (var g in Model.Games)
{
<tr>
<td>@g.Config.Name</td>
<td>@g.PlayerXType vs @g.PlayerOType</td>
<td>@g.Moves.Count</td>
<td>@g.NextMoveBy</td>
<td>@g.CreatedAtUtc.ToLocalTime().ToString("g")</td>
<td>
<a asp-page="./Play" asp-route-gameId="@g.Id" asp-route-token="@g.PlayerXToken">Play as X</a>
<a asp-page="./Play" asp-route-gameId="@g.Id" asp-route-token="@g.PlayerOToken">Play as O</a>
<a asp-page="./Play" asp-route-gameId="@g.Id">Watch</a>
<form method="post" asp-page-handler="Delete" asp-route-gameId="@g.Id" class="d-inline">
<button type="submit" class="btn btn-link">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>
If your List() returns summaries (id, config name, created, move count) instead of full states with boards — better, the list page needs nothing else. "Play as X / O" on the list is the lobby for the laptop the app runs on; the secret in the per-player link protects the opponent you send it to. If you go the username-cookie route (below), the list shows only the seats that belong to the current name.
Creating a game from a configuration: Pages/Games/Create
GameState grows a few web-facing properties. Player types and difficulty you already have from A5; the tokens are two more strings that the console app ignores. Because the state is stored as JSON in SavedGame.StateJson, nothing changes in the database schema — no migration.
public enum EPlayerType { Human, Ai }
public class GameState
{
public Guid Id { get; set; } = Guid.NewGuid();
public EGamePiece[][] Board { get; set; } = [];
public EGamePiece NextMoveBy { get; set; } = EGamePiece.X;
public GameConfiguration Config { get; set; } = default!;
public List<(int Row, int Col)> Moves { get; set; } = [];
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public EPlayerType PlayerXType { get; set; } = EPlayerType.Human;
public EPlayerType PlayerOType { get; set; } = EPlayerType.Human;
public int AiDifficulty { get; set; } = 1;
// web only — console games leave these empty
public string PlayerXToken { get; set; } = "";
public string PlayerOToken { get; set; } = "";
public EPlayerType PlayerTypeOf(EGamePiece piece) =>
piece == EGamePiece.X ? PlayerXType : PlayerOType;
}
The create page picks a configuration and the two player types:
public class CreateModel(IConfigRepository configs, IGameRepository games) : PageModel
{
[BindProperty, Required] public string ConfigName { get; set; } = "";
[BindProperty] public EPlayerType PlayerXType { get; set; } = EPlayerType.Human;
[BindProperty] public EPlayerType PlayerOType { get; set; } = EPlayerType.Human;
[BindProperty, Range(1, 3)] public int AiDifficulty { get; set; } = 1;
public SelectList ConfigOptions { get; private set; } = default!;
public void OnGet(string? configName)
{
ConfigName = configName ?? "";
LoadOptions();
}
public IActionResult OnPost()
{
var config = configs.Get(ConfigName);
if (config is null)
{
ModelState.AddModelError(nameof(ConfigName), "Unknown configuration.");
}
if (!ModelState.IsValid)
{
LoadOptions();
return Page();
}
var game = GameBrain.NewGame(config!);
game.PlayerXType = PlayerXType;
game.PlayerOType = PlayerOType;
game.AiDifficulty = AiDifficulty;
game.PlayerXToken = PlayModel.NewToken();
game.PlayerOToken = PlayModel.NewToken();
games.Save(game);
return RedirectToPage("./Play", new { gameId = game.Id, token = game.PlayerXToken });
}
private void LoadOptions() =>
ConfigOptions = new SelectList(configs.List(), nameof(GameConfiguration.Name), nameof(GameConfiguration.Name));
}
@page
@using GameEngine
@model WebApp.Pages.Games.CreateModel
@{ ViewData["Title"] = "New game"; }
<h1>New game</h1>
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<label asp-for="ConfigName">Configuration</label>
<select asp-for="ConfigName" asp-items="Model.ConfigOptions" class="form-select"></select>
<span asp-validation-for="ConfigName" class="text-danger"></span>
<label asp-for="PlayerXType">X</label>
<select asp-for="PlayerXType" asp-items="Html.GetEnumSelectList<EPlayerType>()" class="form-select"></select>
<label asp-for="PlayerOType">O</label>
<select asp-for="PlayerOType" asp-items="Html.GetEnumSelectList<EPlayerType>()" class="form-select"></select>
<label asp-for="AiDifficulty">AI difficulty (1-3)</label>
<input asp-for="AiDifficulty" type="number" min="1" max="3" class="form-control" />
<button type="submit" class="btn btn-primary">Start</button>
</form>
GameBrain.NewGame(config) is the same factory the console app calls — empty board of the configured size, X to move. The creator is redirected to Play with the X token and sees the O link to share.
Players in different browsers: per-player secret links
The rule is simple: whoever knows the X token plays X, whoever knows the O token plays O, everybody else watches. The token travels in the query string, /Games/Play/{gameId}?token=…, so a player's "login" is a bookmark. Not bank-grade, entirely adequate for a course app, and the whose-turn logic is the same one you would write with real accounts.
public class PlayModel(IGameRepository games) : PageModel
{
public GameState Game { get; private set; } = default!;
public string? Token { get; private set; }
public EGamePiece Me { get; private set; } // Empty = spectator
public EGamePiece Winner { get; private set; }
public bool IsDraw { get; private set; }
public bool Auto { get; private set; }
public bool IsGameOver => Winner != EGamePiece.Empty || IsDraw;
public bool CanMove => !IsGameOver
&& Me == Game.NextMoveBy
&& Game.PlayerTypeOf(Me) == EPlayerType.Human;
public (int Row, int Col)? LastMove => Game.Moves.Count > 0 ? Game.Moves[^1] : null;
public BoardView Board => new(Game, CanMove, Token, LastMove);
public StatusView Status => new(Game.NextMoveBy, Winner, IsDraw, Me, CanMove);
public IActionResult OnGet(Guid gameId, string? token, bool auto)
{
var game = games.Get(gameId);
if (game is null) return NotFound();
if (game.PlayerXToken == "") // started in console: hand out the seats now
{
game.PlayerXToken = NewToken();
game.PlayerOToken = NewToken();
games.Save(game);
return RedirectToPage(new { gameId, token = game.PlayerXToken });
}
Load(game, token);
Auto = auto;
return Page();
}
private void Load(GameState game, string? token)
{
Game = game;
Token = token;
Me = Resolve(game, token);
var brain = new GameBrain(game);
Winner = brain.CheckWin();
IsDraw = brain.IsDraw();
}
private static EGamePiece Resolve(GameState game, string? token) => token switch
{
null or "" => EGamePiece.Empty,
_ when token == game.PlayerXToken => EGamePiece.X,
_ when token == game.PlayerOToken => EGamePiece.O,
_ => EGamePiece.Empty
};
public static string NewToken() => Guid.NewGuid().ToString("N");
}
Guid.NewGuid() is cryptographically random on every platform .NET runs on; 122 bits of entropy is plenty. RandomNumberGenerator.GetHexString(32) is the explicit alternative. What matters is that the token is unguessable and never appears on a page the other player can see.
The invite box on Play.cshtml, visible only to X before the first move:
@if (Model.Me == EGamePiece.X && Model.Game.Moves.Count == 0)
{
<div class="alert alert-info">
Send this link to your opponent:
<code>@Url.Page("./Play", null, new { gameId = Model.Game.Id, token = Model.Game.PlayerOToken }, Request.Scheme)</code>
</div>
}
Whose turn is it?
The check is in the POST handler, not in the view. Hiding the forms in the view is a courtesy; the server-side check is the rule — anybody can craft a POST with curl.
public async Task<IActionResult> OnPostAsync(Guid gameId, string? token, int row, int col,
int expectedMoves, CancellationToken ct)
{
var game = games.Get(gameId);
if (game is null) return NotFound();
var me = Resolve(game, token);
if (me != game.NextMoveBy || game.PlayerTypeOf(me) != EPlayerType.Human)
{
TempData["Error"] = "It is not your turn.";
return RedirectToPage(new { gameId, token });
}
if (game.Moves.Count != expectedMoves)
{
TempData["Error"] = "The board changed while you were thinking. Look again.";
return RedirectToPage(new { gameId, token });
}
var brain = new GameBrain(game);
if (!brain.MakeMove(row, col))
{
TempData["Error"] = $"Illegal move ({row}, {col}).";
return RedirectToPage(new { gameId, token });
}
games.Save(game);
await TryAiMoveAsync(game, ct); // human vs AI: answer inside the same request
return RedirectToPage(new { gameId, token });
}
A spectator resolves to EGamePiece.Empty, which never equals NextMoveBy, so the same line rejects spectators, the wrong player, and a human trying to move for the AI. The expectedMoves value comes back from a hidden route value in every cell form and is explained under "Concurrency" below. Note that token is carried through every redirect — lose it once and the player becomes a spectator.
Alternatives: a username in a cookie, a per-game password
Tokens are one of three acceptable DIY schemes. The other two:
Username in a cookie. The front page asks for a name once and stores it in a cookie; games remember PlayerXName / PlayerOName instead of tokens, and "join" means "take the free seat with my name".
// Pages/Index.cshtml.cs
public class IndexModel : PageModel
{
[BindProperty, Required, StringLength(30)]
public string Name { get; set; } = "";
public string? CurrentName => Request.Cookies["player"];
public IActionResult OnPost()
{
if (!ModelState.IsValid) return Page();
Response.Cookies.Append("player", Name, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Expires = DateTimeOffset.UtcNow.AddDays(30)
});
return RedirectToPage("/Games/Index");
}
}
Request.Cookies["player"] is available in every PageModel. Two browsers means two cookies, so the same laptop can still play against itself in a normal and a private window. Nothing stops a user from typing someone else's name — that is the "DIY" part.
Per-game password. The creator sets a password; the join page asks for it and, on success, redirects to the link with the O token. The password is checked once, the token does the rest. Store a hash if you want to look tidy, but understand this is a classroom game, not a login system.
ASP.NET Core Identity is not required in this course — it is bonus territory. The next course, Web Applications with C#, spends two weeks on it. Any of the three DIY schemes plus a server-side whose-turn check gets full points for "players in different browsers".
What about HttpContext.Session? builder.Services.AddSession() + app.UseSession() gives you a per-browser dictionary keyed by a session cookie: HttpContext.Session.SetString("player", name). It is in-memory by default (gone on restart), it is invisible to the console app, and every value in it is state you also have to keep in the database anyway. We keep player identity in the URL or a cookie and everything else in GameState — one source of truth that both front-ends read.
Partial views: _Board and _Status
The Play page is getting long, and the auto-refresh option with htmx below needs the board as a separately renderable piece. Two partials with small view models:
// WebApp/ViewModels/BoardView.cs
public record BoardView(GameState Game, bool CanMove, string? Token, (int Row, int Col)? LastMove);
public record StatusView(EGamePiece NextMoveBy, EGamePiece Winner, bool IsDraw, EGamePiece Me, bool CanMove);
@* Pages/Shared/_Status.cshtml *@
@using GameEngine
@model WebApp.ViewModels.StatusView
<p class="status">
@if (Model.Winner != EGamePiece.Empty) { <strong>@Model.Winner wins!</strong> }
else if (Model.IsDraw) { <strong>Draw.</strong> }
else if (Model.CanMove) { <strong>Your move (@Model.Me).</strong> }
else if (Model.Me == EGamePiece.Empty) { <span>Spectating. Next move: @Model.NextMoveBy</span> }
else { <span>Waiting for @Model.NextMoveBy…</span> }
</p>
_Board.cshtml is the table from lecture 13.1 with three changes: @model WebApp.ViewModels.BoardView, cells are forms only when Model.CanMove, and every form carries asp-route-token="@Model.Token" and asp-route-expectedMoves="@Model.Game.Moves.Count". The page itself shrinks to:
@page "{gameId:guid}"
@using GameEngine
@model WebApp.Pages.Games.PlayModel
@{ ViewData["Title"] = Model.Game.Config.Name; }
<h1>@Model.Game.Config.Name</h1>
@if (TempData["Error"] is string error)
{
<div class="alert alert-warning">@error</div>
}
<partial name="_Status" model="Model.Status" />
<partial name="_Board" model="Model.Board" />
Files starting with _ in Pages/Shared are found by <partial name="…"> from any page. A partial has no PageModel and no handlers — it is a template with a model, nothing more.
Auto-refresh for the waiting player
Player O submitted a move and is now looking at "Waiting for X…". Nothing will happen in that browser until it asks the server again. Three ways to make it ask:
| Option | Cost | Downside |
|---|---|---|
<meta http-equiv="refresh" content="3"> in the page | one line | reloads every 3 s whether or not anything changed, flickers, resets scrolling |
JS fetch poll of a JSON handler, reload on change | ~10 lines of JS + one handler | you write a little JavaScript |
htmx hx-get + hx-trigger="every 2s" swapping the board partial | 3 attributes + a library | an external script; bonus territory |
We pick the poll. A named handler returns the move count as JSON:
public IActionResult OnGetState(Guid gameId)
{
var game = games.Get(gameId);
if (game is null) return NotFound();
return new JsonResult(new { moves = game.Moves.Count, next = game.NextMoveBy.ToString() });
}
OnGetState is reachable as ?handler=State. The page drops a marker only when the viewer cannot move, and the script reloads when the count differs:
@if (!Model.CanMove && !Model.IsGameOver)
{
<div data-poll data-moves="@Model.Game.Moves.Count"></div>
}
@section Scripts {
<script src="~/js/poll.js" asp-append-version="true"></script>
}
// wwwroot/js/poll.js
const marker = document.querySelector('[data-poll]');
if (marker) {
const seen = Number(marker.dataset.moves);
setInterval(async () => {
const res = await fetch('?handler=State', { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const state = await res.json();
if (state.moves !== seen) location.reload();
}, 2000);
}
fetch('?handler=State') keeps the path (/Games/Play/{gameId}) and replaces the query, so the handler sees the right game; location.reload() keeps the original URL, token included. The player who can move gets no marker and no polling — nothing to wait for.
For the record, the htmx version replaces the marker and the script with one wrapper and one handler that returns Partial("_Board", Board):
<div hx-get="?handler=Board&token=@Model.Token" hx-trigger="every 2s" hx-swap="outerHTML">
<partial name="_Board" model="Model.Board" />
</div>
AI moves in the web
In the console the AI-vs-human loop is a while in ConsoleUI. In the web there is no loop — there are requests. The natural place for the AI's answer is inside the human's POST: apply the human move, save, and if the next player is an AI, compute its move and save again, then redirect. The human sees both moves on the next GET.
private async Task<bool> TryAiMoveAsync(GameState game, CancellationToken ct)
{
var brain = new GameBrain(game);
if (brain.CheckWin() != EGamePiece.Empty || brain.IsDraw()) return false;
if (game.PlayerTypeOf(game.NextMoveBy) != EPlayerType.Ai) return false;
IMoveProvider ai = new MinimaxMoveProvider(game.AiDifficulty);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
var (row, col) = await ai.GetMoveAsync(game, cts.Token);
brain.MakeMove(row, col);
games.Save(game);
return true;
}
Three things to notice:
IMoveProvider.GetMoveAsync(GameState, CancellationToken)is the abstraction from lecture 51. Built the way lecture 10.2 built it, the provider returns the best move found so far when the token fires (iterative deepening), so theawaitalways completes within the budget. Five seconds is the syllabus limit for the highest level; scale it down for lower difficulties.- The linked token also fires when the browser disconnects (
ctis the request-aborted token). No point finishing a search nobody will see. - Construct the provider the way your console app does. If the console resolves it through a factory registered in DI, register the same factory in
WebApp— one place decides what "difficulty 3" means.
The request takes as long as the AI thinks. The browser shows its loading spinner and the human waits, exactly like in the console. Do not try to move the search to a background thread and return early — you would need a place to keep the running search, and that place would be the static field from the warning in lecture 65.
AI vs AI: stepping
With two AI players there is no human POST to hang the move on. A "Next AI move" button POSTs to a Step handler that runs TryAiMoveAsync once; an auto route value makes the page press the button by itself.
public async Task<IActionResult> OnPostStepAsync(Guid gameId, string? token, bool auto, CancellationToken ct)
{
var game = games.Get(gameId);
if (game is null) return NotFound();
await TryAiMoveAsync(game, ct);
return RedirectToPage(new { gameId, token, auto });
}
@if (!Model.IsGameOver && Model.Game.PlayerTypeOf(Model.Game.NextMoveBy) == EPlayerType.Ai)
{
<form method="post" asp-page="./Play" asp-page-handler="Step" id="step"
asp-route-gameId="@Model.Game.Id" asp-route-token="@Model.Token" asp-route-auto="@Model.Auto">
<button type="submit" class="btn btn-secondary">Next AI move</button>
<a asp-page="./Play" asp-route-gameId="@Model.Game.Id" asp-route-token="@Model.Token"
asp-route-auto="@(!Model.Auto)">@(Model.Auto ? "Stop" : "Auto-play")</a>
</form>
@if (Model.Auto)
{
<script>setTimeout(() => document.getElementById('step').requestSubmit(), 1500);</script>
}
}
The same button covers "AI is X and must open the game" in human-vs-AI: after Create nobody has POSTed yet, the next player is an AI, the button appears. (Or call TryAiMoveAsync once at the end of the Create handler.) Either way the AI move is always triggered by a POST — a GET that changes the board is still wrong, even when the mover is a machine.
Concurrency: two POSTs for the same game
Player X double-clicks. Or X and O both have a stale page open and both submit. Two requests load the same state, both apply a move, both save — the second save silently overwrites the first, and one move is lost or, worse, both moves land and the turn order is broken.
Level 1 — expected move count (works with JSON and DB). Every cell form carries asp-route-expectedMoves="@Model.Game.Moves.Count"; the handler compares it with the loaded state before applying the move. A stale page has the wrong number and gets "the board changed, look again". This is already in OnPostAsync above. It closes the window for humans; it does not close it completely, because the two requests can still interleave between Get and Save.
Level 2 — optimistic concurrency in EF Core. SavedGame.UpdatedAtUtc becomes a concurrency token:
// DAL.EF/AppDbContext.cs, in OnModelCreating
modelBuilder.Entity<SavedGame>()
.Property(g => g.UpdatedAtUtc)
.IsConcurrencyToken();
EF Core now issues UPDATE … WHERE Id = @id AND UpdatedAtUtc = @original. If another request saved in between, zero rows match and SaveChanges throws DbUpdateConcurrencyException. The repository translates it into a domain exception so the page never references EF:
// GameEngine
public class ConcurrentUpdateException(Guid gameId)
: Exception($"Game {gameId} was changed by another request.");
// DAL.EF/GameRepositoryEf.cs
public void Save(GameState game)
{
var entity = db.SavedGames.Find(game.Id);
if (entity is null)
{
db.SavedGames.Add(new SavedGame
{
Id = game.Id,
StateJson = GameStateJson.Serialize(game),
UpdatedAtUtc = DateTime.UtcNow
});
}
else
{
entity.StateJson = GameStateJson.Serialize(game);
entity.UpdatedAtUtc = DateTime.UtcNow;
}
try
{
db.SaveChanges();
}
catch (DbUpdateConcurrencyException)
{
throw new ConcurrentUpdateException(game.Id);
}
}
In the page, catch ConcurrentUpdateException around games.Save, set TempData["Error"] and redirect — the loser of the race sees the other player's move and tries again. Level 1 is expected from everybody; level 2 is a couple of lines and a good D3 story about "stateless web requests".
One move, end to end
Every arrow into P is a fresh PageModel with a fresh scoped repository. The only thing shared between the two browsers is the row in the database.
Bad ids and error handling
@page "{gameId:guid}"— a URL whose id is not a validGuiddoes not match the route at all and gets a 404 before your code runs.- A well-formed id with no game →
games.Getreturnsnull→return NotFound(). Never let anullreach the view;Model.Game.Configonnullis a 500 with a stack trace. - A wrong token is not an error — it is a spectator. Do not
Forbid(): without an authentication scheme configured that throws instead of returning 403. - Out-of-range
row/col, an occupied cell, a move out of turn:MakeMovereturnsfalse, the page redirects with a message. The engine validates, the page reports. - Everything else (a corrupted JSON file, SQLite locked, a bug) is an exception. Lecture 15.1 turns those into a friendly error page with
UseExceptionHandlerandUseStatusCodePagesWithReExecute.
The pattern behind all five: the URL is untrusted input. Validate it like you validate a form.
Self preparation QA
- How does the web app support unlimited parallel games without a game manager? — Each game is a row with its own
Guid, the id is in the URL via@page "{gameId:guid}", and every request loads only that row; there is no shared in-memory state. - How do two players in different browsers get different rights on the same game? — Each player holds a secret token in their link; the server resolves the token to X, O or spectator and only accepts a POST when the resolved piece equals
NextMoveBy. - Why must the whose-turn check be in the POST handler and not only in the view? — The view only hides forms; anyone can send a POST directly, so the server-side check is the actual rule.
- Why do we prefer the database over
HttpContext.Sessionfor game state? — Session is per-browser, in-memory by default, lost on restart and invisible to the console app; the database is the single source of truth both front-ends read. - How is the AI's answer produced in a web app with no game loop? — Inside the human's POST handler: after saving the human move, if the next player is an AI, compute its move with a cancellation budget, save, then redirect.
- Why must an AI-vs-AI step be a POST and not a page that mutates on GET with meta refresh? — GET must be safe; a refreshing GET that changes state replays on back/prefetch and can run moves the user never asked for.
- What is the double-POST race and what are the two guards? — Two requests load the same state and both save, the second overwriting the first; guard 1 is an expected move count carried in the form, guard 2 is an EF concurrency token on
UpdatedAtUtcthat turns the lost update intoDbUpdateConcurrencyException. - What does
{gameId:guid}buy you compared to{gameId}? — A malformed id does not match the route and gets a 404 automatically; your handler only ever sees a validGuidand still checks that the game exists.