12 KiB
Executable File
Server (actionBlocks_server/)
ASP.NET Core 10 solution ActionBlocks.Server.sln with three projects:
actionBlocks_server/
├── src/ActionBlocks.Game/ # game rules – no web dependencies
│ ├── Core/
│ │ ├── PieceType.cs # enum I,J,L,O,S,Z,T (value = color 1-7) + CellColor
│ │ ├── Cell.cs # Cell(Color, ItemId?) and Point(X, Y)
│ │ ├── PieceDefinitions.cs # shapes + derived rotation states
│ │ ├── Piece.cs # PieceSpec (shape + item) and ActivePiece (spec + position)
│ │ ├── Board.cs # grid of locked cells, line clears, garbage, swap
│ │ ├── RotationSystem.cs # IRotationSystem + SuperRotationSystem (kick tables)
│ │ └── Randomizer.cs # IRandomizer + SevenBagRandomizer
│ ├── Garbage/GarbageCalculator.cs # ClearInfo, IGarbageCalculator, IScoreCalculator
│ ├── Targeting/GarbageTargeter.cs # IGarbageTargeter + CyclingGarbageTargeter
│ ├── Items/
│ │ ├── IItem.cs # IItem + ItemContext
│ │ ├── Items.cs # the five item implementations
│ │ └── ItemRegistry.cs # ItemRegistry (list of items) + ItemSpawner
│ ├── GameSettings.cs # all tunable numbers
│ ├── PlayerGame.cs # one player's simulation + InputAction enum
│ └── Match.cs # one round: players, garbage/item routing, winner
├── src/ActionBlocks.Server/ # web host
│ ├── Program.cs # DI, CORS, SignalR, endpoints, config
│ ├── Hubs/GameHub.cs # SignalR API for clients
│ ├── Lobbies/Lobby.cs # one lobby (players, host, current match)
│ ├── Lobbies/LobbyManager.cs # all lobbies, codes, connection→lobby mapping
│ ├── Lobbies/MatchRunner.cs # BackgroundService: 60 Hz tick + broadcast
│ ├── Dtos/Dtos.cs # records sent over the wire
│ ├── Dtos/SnapshotMapper.cs # game objects → DTOs
│ └── appsettings.json
└── tests/ActionBlocks.Game.Tests/ # xUnit tests
ActionBlocks.Game – the rules library
Coordinates
- x grows to the right, y grows downwards.
BoardhasHeight = VisibleRows + BufferRowsrows (40 by default).- Row
0is the top of the hidden buffer. The visible field is rowsBufferRows … Height-1, which is 20–39 by default. - Piece shapes are offsets inside a square bounding box (2×2, 3×3 or 4×4).
ActivePiece.X/Yis the board position of the box's top-left corner. - Network DTOs convert to visible coordinates (
y - BufferRows), so clients see y = 0 at the top of the visible field. Active-piece cells can have negative y while they are still in the buffer.
Cells and pieces
readonly record struct Cell(byte Color, string? ItemId = null) // Color 0 = empty, 1-7 pieces, 8 garbage
sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items) // what sits in the queue; Items[i] = item in mino i
sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation) // a spec placed on the board
The item belongs to a mino index (0–3), not to a position. PieceDefinitions computes every rotation state by rotating the spawn shape mino by mino, so mino i is always the same physical cell of the piece. That keeps the item attached to the right block through every rotation. The website's preview shapes (src/render/shapes.ts) use the same mino order.
Board
Holds the rows as List<Cell[]>, which makes shifting rows cheap. Main operations:
| Method | Purpose |
|---|---|
Fits(piece) / IsBlocked(x, y) |
Collision checks. Outside the board counts as blocked. |
Place(piece) |
Writes the piece's cells, including its item, into the grid |
FullRows() / ClearRows(rows) |
Line clear. Returns the item ids of removed cells |
AddGarbage(count, hole) |
Pushes rows in from the bottom. Returns true when blocks overflow the top (top out) |
RemoveBottomRow(), Clear() |
Used by items |
SwapContents(other) |
Exchanges the row lists of two boards (Switch item) |
PlayerGame – one player's field
This is the heart of the simulation. Its lifecycle:
stateDiagram-v2
[*] --> Created: new PlayerGame(...)
Created --> Playing: Start() → fill queue, spawn
Playing --> Playing: Update(dt, now) / HandleInput(action)
Playing --> ToppedOut: TopOut() (block out, lock out, overflow, forfeit)
ToppedOut --> [*]
Update(dt, now)is called every tick. It applies gravity (a fractional row counter_gravityProgress) and runs the lock-delay timer. Then it locks if needed.HandleInput(action)handles movement, rotation, soft-drop on/off, hard drop and hold.UseItemis ignored here, because using an item needs a target.Matchhandles it.Lock()is the central routine. It detects spins, places the piece, checks lock-out, clears rows and collects items. It also updates combo, back-to-back, score and level. Then it calculates the attack, cancels pending garbage and queues the remainder in_outgoingAttacks. If no line was cleared, it applies pending garbage. Finally it spawns the next piece.- Garbage in:
QueueGarbage(lines)adds to the pending list. Garbage out:DrainOutgoingAttacks()is read byMatch. - Item hooks:
InsertGarbageNow,RemoveBottomLine,ClearBoard,SwapBoards,InvisibleUntil. After any board change under the active piece,ResolveActiveOverlap()pushes the piece up until it fits. If it can't fit anywhere, the player tops out. - Inventory:
AddItem(capped),TakeFirstItem(FIFO).
Dependencies are injected through the constructor (IRandomizer, ItemSpawner, IRotationSystem, IGarbageCalculator, IScoreCalculator, Random), so tests can control every source of randomness. See tests/.../TestHelpers.cs.
Match – one round
- Constructor. It creates one
PlayerGameper player. All of them share one piece seed, so everyone gets the same sequence. Each player gets its ownRandomfor items and garbage holes. The match starts inCountdown, or directly inRunningwhenCountdownSeconds == 0, which tests use. Update(dt)advances the countdown, or updates all players and then runsAfterStateChange().HandleInput(playerId, action)is ignored unless the match is running and the player is alive.UseItemis resolved here. ThenAfterStateChange()runs.SelectTarget(playerId, targetId)stores the target on thePlayerGame. An invalid or dead target becomes the player themself.Forfeit(playerId)is used when a player disconnects.AfterStateChange()routes every outgoing attack through theIGarbageTargeter, assigns places to newly eliminated players, resets targets that point at dead players, and detects the end of the match.- Events.
ItemUsedEvent(TargetId, ItemId, ItemName)andPlayerEliminatedEvent(PlayerId, Place)are collected and drained once per tick by the host.
Items
public interface IItem
{
string Id { get; } // stable id shared with the frontend ("add_line", ...)
string DisplayName { get; } // shown in the flash
int SpawnWeight => 1; // relative spawn probability
void Apply(ItemContext context);
}
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now);
ItemRegistry.Defaultlists all active items.ItemRegistry.PickRandomchooses an item weighted bySpawnWeight.ItemSpawner.Decorate(type)turns a raw piece type into aPieceSpec. Each mino rollsItemSpawnChanceon its own, so a piece can carry 0–4 items.
GameSettings
GameSettings is an immutable record with init properties, one per tunable number. See Game rules for what each one does. The host binds it from the configuration section Game, so you can override any property with an environment variable named Game__<PropertyName>, e.g. Game__ItemSpawnChance=0.25.
ActionBlocks.Server – the host
Program.cs
- Reads the configuration:
ALLOWED_ORIGINS,HUB_PATH(default/hub),PATH_BASEand theGamesection. - CORS. It allows the listed origins, or any origin when the list is empty, and allows credentials, which SignalR needs.
- SignalR uses JSON with
JsonStringEnumConverter, soInputActiontravels as a string like"HardDrop". - Singletons:
GameSettingsandLobbyManager. Hosted service:MatchRunner. - Endpoints:
GET /healthreturns{"status":"ok"}, and the hub is mapped atHUB_PATH. UsePathBase(PATH_BASE)lets the app accept requests that still carry a proxy prefix. Requests without the prefix work too.
LobbyManager
_lobbiesmaps lobby codes toLobbyobjects._connectionLobbymaps each SignalR connection id to a lobby code. One connection can be in at most one lobby.- Codes are 6 characters from
ABCDEFGHJKLMNPQRSTUVWXYZ23456789. Ambiguous characters (0/O, 1/I) are excluded, and codes come from a cryptographically secure RNG. Joining is case-insensitive. - Names are trimmed, control characters are removed, and they are cut to 16 characters. An empty name becomes
Player N. - Player ids are random 12-hex-character strings. They are deliberately not the connection id, so a connection id never leaks to other clients.
Leaveremoves the player, and deletes the lobby once it is empty.- Failures throw a
LobbyException. The hub turns it into aHubExceptionwhose message reaches the client.
Lobby
PlayersandHostIdare tracked; the first player is the host. If the host leaves, the next player becomes host.MaxPlayers = 8.StartMatch(settings)creates aMatchfrom the current players.EndMatch()storesLastResultsand clearsMatch.Dirtyis set by input so that the next tick broadcasts immediately.Remove(player)also forfeits the player in a running match.ToDto()builds theLobbyDto.
GameHub
The client API. It is documented in detail in Protocol. Every method that touches a lobby locks lobby.Sync. OnDisconnectedAsync treats a disconnect exactly like LeaveLobby.
MatchRunner
A BackgroundService with a PeriodicTimer at 60 Hz. Every tick:
- Measures the real elapsed time and clamps it to at most 0.1 s, so a stall doesn't cause huge jumps.
- For each lobby with a match, inside the lock:
match.Update(delta), thenDrainEvents(). - Builds a snapshot when any of these holds: it is an even tick (≈30 Hz baseline), the lobby is dirty (input happened), or events are present. Building a snapshot whenever events exist means no event is ever dropped.
- Once the match is finished, it waits
ResultsDelay(4 s), then callslobby.EndMatch()and broadcasts aLobbyUpdated. - Sends happen outside the lock, fire-and-forget.
Exceptions inside one lobby are logged and do not affect other lobbies.
DTO mapping
SnapshotMapper converts a Match into a MatchStateDto:
- Only the visible rows are sent.
- The board becomes a compact string with one character per cell.
- Item cells are listed separately.
- The active piece and the ghost are omitted for eliminated players.
The format is in Protocol.
Tests
tests/ActionBlocks.Game.Tests (xUnit) covers:
| File | Covers |
|---|---|
CoreTests.cs |
Rotation states, wall kicks, bag randomizer, row clearing with items, garbage insertion |
PlayerGameTests.cs |
Spawn and gravity, line clears collecting items (from the board and from the piece), hold, pending garbage, attack size, top out, inventory cap |
ItemAndMatchTests.cs |
Each item's effect, Switch overlap resolution, Blackout duration, item use routed through Match with events, winner and places, target fallback, garbage cycling, countdown |
TestHelpers.CreatePlayer(...) builds a PlayerGame with a fixed piece sequence (FixedRandomizer) and a configurable item chance. FillRow(board, y, holes...) prepares board states.
Run them with dotnet test in actionBlocks_server/. The Docker build also runs them, so a failing test fails the image build.