6.3 KiB
Executable File
Architecture
Overview
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:
- sends input commands (
MoveLeft,HardDrop,UseItem, …), and - renders the snapshots it receives.
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.
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/MoveRightcommands 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
LobbyManagerstores lobbies in aConcurrentDictionary.- Each
Lobbyhas aSyncobject. Every access to a lobby and itsMatchhappens insidelock (lobby.Sync): hub calls (input, join, leave, start) and the tick loop. MatchandPlayerGameare 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 |