Files
2026-10-24 21:16:18 +02:00

127 lines
6.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture
## Overview
```mermaid
flowchart LR
subgraph Browser
UI[React app<br/>pages + canvas rendering]
KB[Keyboard input<br/>DAS / ARR]
HC[HubClient<br/>@microsoft/signalr]
KB --> HC
HC --> UI
end
subgraph Proxy[Reverse proxy - existing]
end
subgraph WebsiteContainer[actionblocks-website - nginx]
Static[index.html, JS, CSS,<br/>assets, config.js]
end
subgraph ServerContainer[actionblocks-server - ASP.NET Core]
Hub[GameHub]
LM[LobbyManager]
MR[MatchRunner<br/>60 Hz loop]
GL[ActionBlocks.Game<br/>Match / PlayerGame]
Hub --> LM
MR --> LM
LM --> GL
MR --> GL
end
Browser -- HTTPS --> Proxy
Proxy -- static files --> WebsiteContainer
Proxy -- WebSocket /hub --> ServerContainer
```
The **website container** only serves static files. All game communication goes from the browser straight to the **game server**, through the reverse proxy, over a SignalR WebSocket connection. The two containers never talk to each other. Each one sits in its own Docker network (`actionBlocks_website` and `actionBlocks_server`), and both networks are attached to the reverse proxy.
## Server-authoritative design
The server owns the complete game state. The browser never simulates the game. It only:
1. sends **input commands** (`MoveLeft`, `HardDrop`, `UseItem`, …), and
2. **renders** the snapshots it receives.
```mermaid
sequenceDiagram
participant B as Browser
participant H as GameHub
participant M as Match (under lobby lock)
participant R as MatchRunner (60 Hz)
B->>H: Input("MoveLeft")
H->>M: HandleInput(playerId, MoveLeft)
Note over H: lobby.Dirty = true
loop every ~16.7 ms
R->>M: Update(delta)
R->>M: DrainEvents()
alt every 2nd tick, or Dirty, or events present
R-->>B: MatchState(snapshot)
end
end
```
**Why this approach:**
- **One source of truth.** Interactions between players (garbage, item effects, board swaps) are resolved in one place. Nothing ever needs to be reconciled.
- **Cheating is mostly impossible.** Clients cannot fake line clears, items or piece positions.
- **Game logic is easy to change.** All rules live in one C# library with unit tests.
**Trade-off:** input feels as responsive as the network round trip allows. There is no client-side prediction. On a LAN or a good connection (< 50 ms) this is fine. For high-latency play, the natural next step would be local prediction of the player's own active piece. See [Known limitations](#known-limitations).
To keep the feel responsive anyway:
- **Auto-repeat on the client.** Delayed auto shift (DAS) and the auto-repeat rate (ARR) for held left/right keys run in the browser. The browser sends discrete `MoveLeft` / `MoveRight` commands at the repeat rate.
- **Extra snapshot after input.** Any input marks the lobby *dirty*. The next tick (≤ 16.7 ms later) then always sends a snapshot, even when it isn't a regular broadcast tick.
## Server internals at a glance
The C# solution separates **game rules** from **hosting**:
| Project | Depends on | Contains |
|---|---|---|
| `ActionBlocks.Game` | nothing (plain .NET) | Board, pieces, rotation system, randomizer, `PlayerGame`, `Match`, garbage, items, targeting |
| `ActionBlocks.Server` | `ActionBlocks.Game`, ASP.NET Core | SignalR hub, lobbies, tick loop, network DTOs |
| `ActionBlocks.Game.Tests` | `ActionBlocks.Game`, xUnit | Unit tests |
Because `ActionBlocks.Game` knows nothing about networking, it can be unit tested in isolation and reused, for example for bots or a different transport.
Rules are modular through interfaces. Swap an implementation to change a rule without touching the rest:
| Interface | Default implementation | Decides |
|---|---|---|
| `IRotationSystem` | `SuperRotationSystem` | How pieces rotate and kick off walls/floor |
| `IRandomizer` | `SevenBagRandomizer` | The piece sequence |
| `IGarbageCalculator` | `StandardGarbageCalculator` | How many lines an attack sends |
| `IScoreCalculator` | `StandardScoreCalculator` | Points per clear |
| `IGarbageTargeter` | `CyclingGarbageTargeter` | Who receives garbage |
| `IItem` | 5 item classes | What an item does |
Numeric rules (board size, lock delay, item chance, …) live in the `GameSettings` record. They can be overridden through configuration without recompiling.
## Concurrency model
- `LobbyManager` stores lobbies in a `ConcurrentDictionary`.
- Each `Lobby` has a `Sync` object. **Every access to a lobby and its `Match` happens inside `lock (lobby.Sync)`**: hub calls (input, join, leave, start) and the tick loop.
- `Match` and `PlayerGame` are deliberately **not thread-safe**. They rely on the lobby lock.
- A single background service, `MatchRunner`, ticks all lobbies sequentially at 60 Hz. Network sends happen **outside** the lock and are fire-and-forget, so a slow client never blocks the game loop.
## State lifetime
All state is **in memory**: there is no database. If the server process restarts, every lobby and match is lost. If a player's connection drops, they are removed from their lobby and forfeit any running match. Reconnecting creates a new connection, so the player has to join again.
## Known limitations
These are conscious simplifications. They are good candidates for future work:
| Limitation | Impact | Possible improvement |
|---|---|---|
| No client-side prediction | Movement latency equals the network round trip | Predict the own active piece locally and reconcile with snapshots |
| Full JSON snapshots of all players at ~30–60 Hz | About 1.5 KB per snapshot for 2 players. It grows linearly with players | Send deltas, or use the MessagePack protocol, or send opponents at a lower rate |
| Blackout (invisibility) is hidden client-side | A modified client could still show the board | Strip the board from that player's personal snapshot on the server |
| No reconnect into a running lobby | A network hiccup kicks the player | Issue a session token and re-bind to the player on reconnect |
| In-memory lobbies, single server instance | No horizontal scaling | Use a SignalR backplane plus external state, or sticky sessions per lobby |
| Max 8 players per lobby | Matches the number keys 1–9 (1 = self) | Raise `Lobby.MaxPlayers`; the UI would need paging for targets |