added documentation
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Action Blocks – Developer Documentation
|
||||
|
||||
Action Blocks is an online player-vs-player falling-block game. Players clear lines to send garbage to each other. They also collect **items** hidden inside pieces and use them on a player of their choice.
|
||||
|
||||
The project consists of two deployable components:
|
||||
|
||||
| Component | Folder | Technology | Responsibility |
|
||||
|---|---|---|---|
|
||||
| Game server | `actionBlocks_server/` | C# / ASP.NET Core 10, SignalR | Runs all game logic, manages lobbies and players, pushes game state to clients |
|
||||
| Website | `actionBlocks_website/` | React 19, TypeScript, Vite, CSS Modules, nginx | Renders the game, captures keyboard input, sends it to the server |
|
||||
|
||||
## Reading order
|
||||
|
||||
New to the project? Read these in order:
|
||||
|
||||
1. [Architecture](architecture.md): how the pieces fit together, and why the server is authoritative.
|
||||
2. [Game rules](game-rules.md): what the game actually does, with all numbers.
|
||||
3. [Server](server.md): a walkthrough of the C# code.
|
||||
4. [Protocol](protocol.md): the SignalR API between website and server.
|
||||
5. [Frontend](frontend.md): a walkthrough of the React code.
|
||||
6. [Development](development.md): running, testing and debugging locally.
|
||||
7. [Deployment](deployment.md): Docker, compose, reverse proxy and environment variables.
|
||||
8. [Extending](extending.md): step-by-step guides for common changes (new item, new rule, new graphics, …).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
.
|
||||
├── actionBlocks_server/ C# solution (game logic + web host + tests)
|
||||
│ ├── src/ActionBlocks.Game/ Pure game logic library (no ASP.NET dependency)
|
||||
│ ├── src/ActionBlocks.Server/ ASP.NET Core host: SignalR hub, lobbies, tick loop
|
||||
│ ├── tests/ActionBlocks.Game.Tests/ xUnit tests for the game logic
|
||||
│ └── Dockerfile
|
||||
├── actionBlocks_website/ React frontend
|
||||
│ ├── src/ Application code
|
||||
│ ├── public/ Static files copied as-is (graphics, config.js)
|
||||
│ ├── docker/ nginx config + container start script
|
||||
│ └── Dockerfile
|
||||
├── doc/ This documentation
|
||||
├── docker-compose.yml Runs both containers
|
||||
├── .env.example Template for deployment variables
|
||||
├── instructions.md The original project brief
|
||||
└── tetris_instructions/ Reference material for the base rules (saved web page)
|
||||
```
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Board / field** | The 10×20 grid of a player. Internally it has 20 extra hidden rows above (the *buffer*). |
|
||||
| **Piece** | One of the 7 four-cell shapes (internally named I, J, L, O, S, Z, T). |
|
||||
| **Mino** | One of the four cells of a piece. |
|
||||
| **Active piece** | The piece currently falling and controlled by the player. |
|
||||
| **Ghost** | Preview of where the active piece would land. In the UI it is called the "drop shadow". |
|
||||
| **Hold** | Slot where a player can park one piece. It can be used once per piece. |
|
||||
| **Garbage** | Gray lines with one hole that rise from the bottom. They are sent by opponents' line clears. |
|
||||
| **Item** | A special ability attached to one mino of a piece. You collect it by clearing that mino's row. |
|
||||
| **Target** | The player your items are used on. It is selected with the number keys. |
|
||||
| **Snapshot** | The full match state the server pushes to all clients many times per second. |
|
||||
| **Lobby** | A group of players sharing a 6-character code. It hosts consecutive matches. |
|
||||
| **Match** | One round inside a lobby, from countdown to winner. |
|
||||
|
||||
> Naming note: the project intentionally avoids the trademarked name of the original game in all user-facing text and graphics. Internally, the standard single-letter piece names (I, J, L, …) are used because they are the common technical vocabulary.
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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 |
|
||||
@@ -0,0 +1,129 @@
|
||||
# Deployment
|
||||
|
||||
Both components ship as Docker images and run together through `docker-compose.yml`. An already existing **reverse proxy** handles TLS and routing. Compose therefore publishes no ports.
|
||||
|
||||
## Topology
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[Browser] -->|HTTPS| P[Reverse proxy]
|
||||
P -->|network actionBlocks_website| W[actionblocks-website :80<br/>nginx, static files]
|
||||
P -->|network actionBlocks_server<br/>WebSocket| S[actionblocks-server :8080<br/>ASP.NET Core]
|
||||
```
|
||||
|
||||
| Container | Image built from | Port | Docker network |
|
||||
|---|---|---|---|
|
||||
| `actionblocks-website` | `actionBlocks_website/Dockerfile` | 80 | `actionBlocks_website` (external) |
|
||||
| `actionblocks-server` | `actionBlocks_server/Dockerfile` | 8080 | `actionBlocks_server` (external) |
|
||||
|
||||
Both networks are declared `external: true`. They must exist beforehand and be attached to the proxy:
|
||||
|
||||
```bash
|
||||
docker network create actionBlocks_website
|
||||
docker network create actionBlocks_server
|
||||
```
|
||||
|
||||
## Images
|
||||
|
||||
### Server (`actionBlocks_server/Dockerfile`)
|
||||
|
||||
1. `mcr.microsoft.com/dotnet/sdk:10.0` restores the packages, **runs the unit tests** (a failing test fails the build), then publishes in Release mode.
|
||||
2. `mcr.microsoft.com/dotnet/aspnet:10.0` runs `dotnet ActionBlocks.Server.dll` as the non-root `$APP_UID` user on port 8080.
|
||||
|
||||
### Website (`actionBlocks_website/Dockerfile`)
|
||||
|
||||
1. `node:22-alpine` runs `npm ci && npm run build` (typecheck + Vite build into `dist/`).
|
||||
2. `nginx:1.27-alpine` serves `dist/` with `docker/nginx.conf`.
|
||||
3. `docker/40-runtime-config.sh` is placed in `/docker-entrypoint.d/`. The official nginx image runs that directory's scripts at **every container start**. The script writes `config.js` from the `SERVER_URL` env var, so the same image works in any environment without a rebuild.
|
||||
|
||||
nginx behaviour (`docker/nginx.conf`):
|
||||
|
||||
| Path | Behaviour |
|
||||
|---|---|
|
||||
| `/static/…` | Hashed bundles, cached for a year (`immutable`) |
|
||||
| `/assets/…` | Exchangeable graphics, `no-cache` (revalidated, so replaced files appear immediately) |
|
||||
| `/config.js`, `/index.html` | Never cached |
|
||||
| anything else | File if it exists. Otherwise the first path segment is stripped and the lookup retried (this supports proxies that do not strip a sub-path). Finally falls back to `index.html` |
|
||||
|
||||
## Configuration
|
||||
|
||||
Compose reads a `.env` file next to `docker-compose.yml`. Copy the template and edit it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| Variable | Used by | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SERVER_URL` | website | empty → `<site origin>/hub` | Full hub URL **as the browser sees it**, e.g. `https://game.example.com/server/hub` |
|
||||
| `ALLOWED_ORIGINS` | server | empty → any origin | Comma-separated list of origins allowed to connect, e.g. `https://game.example.com` |
|
||||
| `PATH_BASE` | server | empty | Prefix to accept if the proxy forwards a sub-path **without stripping it**, e.g. `/server` |
|
||||
| `HUB_PATH` | server | `/hub` | Hub endpoint path. It is fixed to `/hub` in the compose file |
|
||||
| `Game__<Setting>` | server | see `GameSettings.cs` | Overrides gameplay values, e.g. `Game__ItemSpawnChance=0.2`. Add these under the server's `environment:` in the compose file |
|
||||
|
||||
Start or update:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker exec actionblocks-website cat /usr/share/nginx/html/config.js # shows SERVER_URL
|
||||
curl https://<your-host>/<server-path>/health # {"status":"ok"} (the server image has no curl/wget, so test through the proxy)
|
||||
```
|
||||
|
||||
## Reverse proxy requirements
|
||||
|
||||
1. Route the website to `actionblocks-website:80`.
|
||||
2. Route the hub to `actionblocks-server:8080`, with **WebSocket upgrade** support. The `Upgrade` and `Connection` headers must be forwarded, and read timeouts must be long because the connection stays open for the whole session.
|
||||
3. If the hub runs on a different origin than the site, list the site's origin in `ALLOWED_ORIGINS`.
|
||||
|
||||
### Sub-paths
|
||||
|
||||
Serving under paths such as `https://game.example.com/website/` and `/server/` is supported:
|
||||
|
||||
- **Website:** works whether the proxy strips `/website` or not. All URLs in the build are relative, and nginx retries unknown paths without their first segment.
|
||||
- **Server:**
|
||||
- If the proxy strips `/server`, nothing else is needed. The hub answers at `/hub`.
|
||||
- If it does not strip the prefix, set `PATH_BASE=/server`.
|
||||
- Set `SERVER_URL` to the public hub URL, e.g. `https://game.example.com/server/hub`.
|
||||
|
||||
### Example: nginx as the reverse proxy
|
||||
|
||||
```nginx
|
||||
# Website under /website/ (prefix stripped by the trailing slash in proxy_pass)
|
||||
location /website/ {
|
||||
proxy_pass http://actionblocks-website:80/;
|
||||
}
|
||||
|
||||
# Game server under /server/ (prefix stripped)
|
||||
location /server/ {
|
||||
proxy_pass http://actionblocks-server:8080/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
```
|
||||
|
||||
Matching `.env`:
|
||||
|
||||
```env
|
||||
SERVER_URL=https://game.example.com/server/hub
|
||||
ALLOWED_ORIGINS=https://game.example.com
|
||||
PATH_BASE=
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Browser console: `Refused to apply style … MIME type ('')`, 404 for `config.js` / `index-*.js` | Assets requested from the wrong path, e.g. an old image built with absolute URLs, or the proxy doesn't route the sub-path | Rebuild the website image, check the proxy route |
|
||||
| Red banner "Server unreachable, retrying..." | `SERVER_URL` wrong, hub not routed, or WebSocket upgrade blocked | Open `<SERVER_URL>/negotiate?negotiateVersion=1` with a POST (e.g. `curl -X POST`); it must return JSON. Check the upgrade headers on the proxy |
|
||||
| CORS error in the console | Site origin not in `ALLOWED_ORIGINS` | Add the exact origin (scheme + host + port) |
|
||||
| Works, then players are kicked after ~60 s idle | Proxy read timeout too short | Raise the proxy's timeout for the hub route |
|
||||
| Changed `.env` has no effect | Containers not recreated | `docker compose up -d` (recreates containers with new env) |
|
||||
| All lobbies vanish | Server container restarted | Expected: state is in memory only |
|
||||
@@ -0,0 +1,88 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Used for |
|
||||
|---|---|---|
|
||||
| .NET SDK | 10.0 | Server and tests |
|
||||
| Node.js + npm | 22 | Website |
|
||||
| Docker + Compose | recent | Optional, for container builds |
|
||||
|
||||
## Running locally
|
||||
|
||||
Two terminals:
|
||||
|
||||
```bash
|
||||
# 1. Game server on http://localhost:5080
|
||||
cd actionBlocks_server
|
||||
dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
||||
|
||||
# 2. Website on http://localhost:5173
|
||||
cd actionBlocks_website
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/hub` (including WebSockets) to `http://localhost:5080`, so the default hub URL `<origin>/hub` just works. To point it at another server, use `DEV_SERVER_URL=http://host:port npm run dev`.
|
||||
|
||||
To play against yourself, open the site in two browser windows. Create a lobby in one and join with the code in the other. Keyboard focus decides which window receives input.
|
||||
|
||||
### Handy server settings for testing
|
||||
|
||||
Any `GameSettings` value can be overridden with an environment variable:
|
||||
|
||||
```bash
|
||||
Game__ItemSpawnChance=0.9 Game__CountdownSeconds=1 \
|
||||
dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
||||
```
|
||||
|
||||
That gives items on almost every piece and a near-instant start, which is useful for testing items.
|
||||
|
||||
## Commands
|
||||
|
||||
| Where | Command | Does |
|
||||
|---|---|---|
|
||||
| `actionBlocks_server/` | `dotnet build` | Builds all projects |
|
||||
| | `dotnet test` | Runs the game logic tests |
|
||||
| `actionBlocks_website/` | `npm run dev` | Dev server with hot reload |
|
||||
| | `npm run typecheck` | TypeScript check only |
|
||||
| | `npm run build` | Typecheck + production build into `dist/` |
|
||||
| | `npm run preview` | Serves `dist/` locally |
|
||||
| repo root | `docker compose build` | Builds both images (server image runs the tests) |
|
||||
| | `docker compose config` | Validates the compose file and `.env` |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Game rules** live in `ActionBlocks.Game` and are covered by unit tests. When you change or add a rule, add a test in `tests/ActionBlocks.Game.Tests`. Use `TestHelpers.CreatePlayer` for a deterministic player: it takes a fixed piece sequence and an item chance (0 or 1). Use `FillRow` to build board situations.
|
||||
- **Networking and lobby flow** has no automated tests yet. Test it manually, or with a small script client (see [Protocol → Writing a test client](protocol.md#writing-a-test-client)).
|
||||
- **Bots are an effective test tool.** A script client that joins a lobby and plays with a simple heuristic shows how garbage and items work under real conditions: it clears lines, collects items and uses them on you. One works like this:
|
||||
1. Read your board from the snapshot.
|
||||
2. For every rotation and x position of the active piece, simulate dropping it and score the result: lines cleared minus holes, height and bumpiness.
|
||||
3. Send the best move's `RotateCw` / `MoveLeft` / `MoveRight` / `HardDrop` inputs.
|
||||
4. Send `UseItem` whenever the inventory is not empty.
|
||||
- **Frontend** has no unit tests. `npm run build` (which includes the typecheck) catches type errors, and visual checks happen in the browser.
|
||||
|
||||
## Debugging tips
|
||||
|
||||
- **Server logs** go to stdout. Exceptions in the tick loop are logged per lobby as `Failed to update lobby {Code}`.
|
||||
- **Inspect live snapshots.** In the browser devtools, open Network → WS → the `/hub` connection → Messages. Each `MatchState` frame shows the full state, and its records are separated by `\u001e`.
|
||||
- `GET /health` on the server tells you whether the process is up.
|
||||
- **Is the hub reachable?** `curl -X POST "<hub-url>/negotiate?negotiateVersion=1"` must return JSON listing the available transports.
|
||||
|
||||
## Code conventions
|
||||
|
||||
**Server**
|
||||
|
||||
- File-scoped namespaces, nullable reference types enabled, one main type per file. Small closely related types may share a file, e.g. all five items in `Items.cs`.
|
||||
- `ActionBlocks.Game` must stay free of ASP.NET or serialization dependencies. Mapping to the wire format happens in `ActionBlocks.Server/Dtos`.
|
||||
- Game objects are not thread-safe. Only touch them inside `lock (lobby.Sync)`.
|
||||
- Prefer adding a value to `GameSettings`, or an implementation of an existing interface, over hard-coding numbers in logic.
|
||||
|
||||
**Website**
|
||||
|
||||
- Function components and hooks. No state library: server state is owned by `useGameConnection`.
|
||||
- Styling only through `*.module.css`, plus the CSS variables in `theme/global.css`. Don't use inline styles except for computed sizes and colors.
|
||||
- Reference graphics and colors only through `theme/assets.ts` and `theme/colors.ts`.
|
||||
- Keep `net/types.ts` in sync with the server DTOs.
|
||||
- UI text in the pixel font: ASCII only.
|
||||
- No trademarked names in user-facing text or assets.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Extending the Game
|
||||
|
||||
Step-by-step guides for the most common changes.
|
||||
|
||||
- [Add a new item](#add-a-new-item)
|
||||
- [Change a numeric rule](#change-a-numeric-rule)
|
||||
- [Replace a rule implementation](#replace-a-rule-implementation)
|
||||
- [Add new state to the snapshot](#add-new-state-to-the-snapshot)
|
||||
- [Add a new hub method](#add-a-new-hub-method)
|
||||
- [Add a new match event](#add-a-new-match-event)
|
||||
- [Change graphics and colors](#change-graphics-and-colors)
|
||||
- [Change key bindings](#change-key-bindings)
|
||||
|
||||
---
|
||||
|
||||
## Add a new item
|
||||
|
||||
Example: a **"Speed Up"** item that makes the target's pieces fall fast for 5 seconds.
|
||||
|
||||
### 1. Server: implement `IItem`
|
||||
|
||||
Add a class in `actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs`, or in a new file in that folder:
|
||||
|
||||
```csharp
|
||||
public sealed class SpeedUpItem : IItem
|
||||
{
|
||||
public string Id => "speed"; // stable id, also used by the frontend
|
||||
public string DisplayName => "Speed Up";
|
||||
public int SpawnWeight => 1; // optional, default 1 (higher = more common)
|
||||
|
||||
public void Apply(ItemContext context) =>
|
||||
context.Target.FastUntil = Math.Max(context.Target.FastUntil, context.Now + 5);
|
||||
}
|
||||
```
|
||||
|
||||
`ItemContext` gives you the `User`, the `Target`, a shared `Random` and the match time `Now`. Use `context.IsSelfTarget` to special-case self-use.
|
||||
|
||||
### 2. Server: add any new state the item needs
|
||||
|
||||
If existing `PlayerGame` methods (`InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`) aren't enough, add a property or method to `PlayerGame`. For the example:
|
||||
|
||||
```csharp
|
||||
public double FastUntil { get; set; }
|
||||
// in Update(): if (Now < FastUntil) rowsPerSecond *= 4;
|
||||
```
|
||||
|
||||
If the board changes under the active piece, call `ResolveActiveOverlap()` afterwards. That method is private, so add your board-changing method to `PlayerGame` itself, as the existing item hooks do.
|
||||
|
||||
### 3. Server: register it
|
||||
|
||||
In `Items/ItemRegistry.cs`:
|
||||
|
||||
```csharp
|
||||
public static ItemRegistry Default { get; } = new(
|
||||
[
|
||||
new AddGarbageLineItem(),
|
||||
...
|
||||
new SpeedUpItem(),
|
||||
]);
|
||||
```
|
||||
|
||||
### 4. Server: test it
|
||||
|
||||
Add a test to `tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs`, and update `Registry_contains_five_unique_items` to the new count.
|
||||
|
||||
### 5. Website: add the visuals
|
||||
|
||||
Add an icon at `actionBlocks_website/public/assets/items/speed.svg`. Any image format works; the existing icons are 8×8 pixel-art SVGs. Then add an entry to `itemVisuals` in `src/theme/assets.ts`:
|
||||
|
||||
```ts
|
||||
speed: {
|
||||
icon: asset('assets/items/speed.svg'),
|
||||
name: 'Speed Up',
|
||||
color: '#ff9b26',
|
||||
description: 'Target pieces fall very fast for 5 seconds.',
|
||||
},
|
||||
```
|
||||
|
||||
The id key must match `IItem.Id`. Unknown ids still work but show `unknown.svg`.
|
||||
|
||||
### 6. Website: show the effect (only if needed)
|
||||
|
||||
If the item needs a visual effect, as Blackout does, expose the state in the snapshot (see [below](#add-new-state-to-the-snapshot)) and use it in `GamePage` / `draw.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Change a numeric rule
|
||||
|
||||
All numbers are in `ActionBlocks.Game/GameSettings.cs`. There are two ways to change one:
|
||||
|
||||
- **Change the default** in `GameSettings.cs` and rebuild.
|
||||
- **Override it per deployment** without rebuilding: set `Game__<Property>` on the server container, e.g. in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
actionblocks-server:
|
||||
environment:
|
||||
Game__ItemSpawnChance: "0.15"
|
||||
Game__MaxInventory: "6"
|
||||
Game__LockDelaySeconds: "0.6"
|
||||
```
|
||||
|
||||
To add a **new** setting, add an `init` property with a default to `GameSettings` and read it where needed. Configuration binding picks it up automatically.
|
||||
|
||||
If you change the board width or height, the website adapts automatically, because `MatchState.width`/`height` drive the rendering.
|
||||
|
||||
---
|
||||
|
||||
## Replace a rule implementation
|
||||
|
||||
The rules sit behind interfaces (see [Architecture](architecture.md#server-internals-at-a-glance)). To swap one, implement the interface and pass it where `Match` builds the players. That happens in the `Match` constructor (`Match.cs`):
|
||||
|
||||
```csharp
|
||||
var rotation = new SuperRotationSystem(); // IRotationSystem
|
||||
var garbage = new StandardGarbageCalculator(); // IGarbageCalculator
|
||||
var score = new StandardScoreCalculator(); // IScoreCalculator
|
||||
... new SevenBagRandomizer(new Random(pieceSeed)) // IRandomizer
|
||||
```
|
||||
|
||||
The garbage targeter can already be passed to the `Match` constructor (`IGarbageTargeter? garbageTargeter`). For example, to send garbage to the player with the highest score, implement `IGarbageTargeter.NextTarget`. Then, in `Lobby.StartMatch`, pass `new Match(players, settings, garbageTargeter: new MyTargeter())`.
|
||||
|
||||
If you want rules selectable per lobby, add constructor parameters to `Match` in the same style and choose them in `Lobby.StartMatch`.
|
||||
|
||||
---
|
||||
|
||||
## Add new state to the snapshot
|
||||
|
||||
1. **Game:** expose the value as a public property on `PlayerGame` (or `Match`).
|
||||
2. **DTO:** add a parameter to `PlayerStateDto` (or `MatchStateDto`) in `ActionBlocks.Server/Dtos/Dtos.cs`.
|
||||
3. **Mapper:** fill it in `SnapshotMapper.ToDto`.
|
||||
4. **Website types:** add the camelCase field to `PlayerState` (or `MatchState`) in `src/net/types.ts`.
|
||||
5. **Website UI:** use it in `GamePage`, `MiniBoard` or `draw.ts`.
|
||||
|
||||
Keep snapshots small. They are sent ~30 times per second for every player to every client.
|
||||
|
||||
**Private information.** Snapshots go to everyone in the lobby. Don't put anything there that opponents must not see, such as the selected target. If you need per-player private data, send a separate message to `Clients.Client(connectionId)` from `MatchRunner`.
|
||||
|
||||
---
|
||||
|
||||
## Add a new hub method
|
||||
|
||||
1. Add a public method to `GameHub`. If it touches a lobby, use `WithPlayer(...)` or `lock (lobby.Sync)`. Throw `HubException` with a user-readable message on failure.
|
||||
2. Add a wrapper to `HubClient` in `src/net/hubClient.ts`. Use `invoke` when you need a result or an error, `send` for fire-and-forget.
|
||||
3. Expose it from `useGameConnection` in its `actions` object.
|
||||
4. Document it in [Protocol](protocol.md).
|
||||
|
||||
---
|
||||
|
||||
## Add a new match event
|
||||
|
||||
1. Add a record deriving from `MatchEvent` in `Match.cs` and add it to `_events` where it happens. If the source is inside `PlayerGame`, collect it there and drain it in `Match.AfterStateChange`, the same way outgoing attacks are drained.
|
||||
2. Map it in `SnapshotMapper.ToDto(MatchEvent)` to a `MatchEventDto` with a new `type` string, adding DTO fields if needed.
|
||||
3. Handle the `type` in `useGameConnection`'s `onMatchState`, e.g. to show a "DOUBLE!" popup.
|
||||
|
||||
---
|
||||
|
||||
## Change graphics and colors
|
||||
|
||||
| Change | Edit |
|
||||
|---|---|
|
||||
| Item icon | Replace the file in `public/assets/items/`, or point `itemVisuals[id].icon` elsewhere |
|
||||
| Logo | Put an image in `public/assets/`, set `uiAssets.logo: asset('assets/logo.png')` |
|
||||
| Block colors | `src/theme/colors.ts` (`blockColors[1..7]` pieces, `[8]` garbage) |
|
||||
| Block style (bevel, texture, …) | `drawBlock` in `src/render/draw.ts` |
|
||||
| Ghost look | `drawGhostCell` in `draw.ts`, `boardColors.ghostAlpha` |
|
||||
| UI colors, borders, font | CSS variables in `src/theme/global.css` |
|
||||
| A component's layout | Its `*.module.css` file |
|
||||
|
||||
In production, the files in `public/assets/` are served with `no-cache`, so replaced images show up right after a redeploy.
|
||||
|
||||
---
|
||||
|
||||
## Change key bindings
|
||||
|
||||
Edit `src/config/keybindings.ts`:
|
||||
|
||||
- `keyBindings` maps each action to a list of `KeyboardEvent.code` values. Use physical codes such as `KeyW` or `ArrowLeft`; a site like keycode.info shows them.
|
||||
- `targetKeys` lists the keys for target slots 1–9.
|
||||
- `DAS_MS` / `ARR_MS` set the auto-repeat timing.
|
||||
- `controlsHelp` holds the texts shown in the lobby's Controls panel. Keep them ASCII.
|
||||
|
||||
To add a new **action**, add it to `BoundAction` and `keyBindings`. If it maps 1:1 to a server action, add it to `simpleActions`. A new server action also needs an `InputAction` value on the server, handled in `PlayerGame.HandleInput` or `Match.HandleInput`, plus the matching string in `src/net/types.ts`.
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# Frontend (`actionBlocks_website/`)
|
||||
|
||||
React 19 + TypeScript, bundled with Vite. Styling uses **CSS Modules only**: every component has a `*.module.css` next to it. The only global stylesheet is `src/theme/global.css`, which holds design tokens and the base reset. The game boards are drawn on `<canvas>`.
|
||||
|
||||
```
|
||||
actionBlocks_website/
|
||||
├── index.html # trailing-slash redirect, loads config.js and the app
|
||||
├── vite.config.ts # base './', output dir 'static', dev proxy for /hub
|
||||
├── public/ # copied to the build output unchanged
|
||||
│ ├── config.js # runtime config (overwritten in the container)
|
||||
│ └── assets/ # exchangeable graphics
|
||||
│ ├── favicon.svg
|
||||
│ └── items/*.svg # one icon per item id (+ unknown.svg)
|
||||
├── docker/
|
||||
│ ├── nginx.conf
|
||||
│ └── 40-runtime-config.sh # writes config.js from env SERVER_URL
|
||||
└── src/
|
||||
├── main.tsx # entry: preload assets, render <App/>
|
||||
├── App.tsx # top-level screen switch
|
||||
├── config/
|
||||
│ ├── runtimeConfig.ts # resolves the hub URL
|
||||
│ └── keybindings.ts # all key bindings, DAS/ARR timings, help texts
|
||||
├── net/
|
||||
│ ├── types.ts # TypeScript mirror of the server DTOs
|
||||
│ ├── hubClient.ts # typed SignalR wrapper
|
||||
│ └── useGameConnection.ts # React hook: connection + all server state
|
||||
├── input/useKeyboardInput.ts # keyboard → game actions (with DAS/ARR)
|
||||
├── render/
|
||||
│ ├── draw.ts # canvas drawing: blocks, items, ghost, board, previews
|
||||
│ └── shapes.ts # piece shapes for previews
|
||||
├── theme/
|
||||
│ ├── global.css # CSS variables (colors, font), base styles
|
||||
│ ├── colors.ts # block colors used on canvas
|
||||
│ └── assets.ts # registry of every graphic + item visuals
|
||||
├── pages/ # HomePage, LobbyPage, GamePage (+ .module.css)
|
||||
└── components/ # BoardCanvas, MiniBoard, PiecePreview, ItemBar,
|
||||
# ItemFlashOverlay, Logo, ui/Button, ui/Panel
|
||||
```
|
||||
|
||||
## Data flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Hub[HubClient] -- LobbyUpdated / MatchState --> Hook[useGameConnection]
|
||||
Hook -- status, playerId, lobby, match, flashes --> App
|
||||
App -->|no lobby| Home[HomePage]
|
||||
App -->|lobby waiting| Lobby[LobbyPage]
|
||||
App -->|match present| Game[GamePage]
|
||||
Game --> KB[useKeyboardInput]
|
||||
KB -- sendInput / selectTarget --> Hook
|
||||
Hook --> Hub
|
||||
```
|
||||
|
||||
The app has **no router and no global store**. All server-derived state lives in a single hook, `useGameConnection`. `App` picks the screen from that state:
|
||||
|
||||
| State | Screen |
|
||||
|---|---|
|
||||
| no lobby or no player id | `HomePage`: enter a name, create a lobby or join one by code |
|
||||
| `match` present | `GamePage` |
|
||||
| lobby `phase === 'playing'` but no snapshot yet | "Starting..." placeholder |
|
||||
| otherwise | `LobbyPage`: code, invite link, players, Start (host), last results, controls and item help |
|
||||
|
||||
### `useGameConnection` (`src/net/useGameConnection.ts`)
|
||||
|
||||
- Creates one `HubClient` on mount and starts it. If the connection fails it retries every 2 s.
|
||||
- Keeps `status`, `playerId`, `lobby`, `match` and `flashes` in React state.
|
||||
- On `LobbyUpdated`, it stores the lobby. When the phase is `waiting`, it also clears `match`, which returns the UI to the lobby screen.
|
||||
- On `MatchState`, it stores the snapshot. Every `itemUsed` event becomes an **`ItemFlash`** `{ key, targetId, text, color }`, which is removed again after 1.4 s.
|
||||
- When the connection is lost it clears lobby, match and player id. The server has removed the player by then (see [Architecture](architecture.md#state-lifetime)).
|
||||
- It exposes stable action callbacks: `createLobby`, `joinLobby`, `leaveLobby`, `startGame`, `sendInput`, `selectTarget`.
|
||||
|
||||
### `HubClient` (`src/net/hubClient.ts`)
|
||||
|
||||
A thin wrapper around `@microsoft/signalr`:
|
||||
|
||||
- Request/response methods (`createLobby`, `joinLobby`, `leaveLobby`, `startGame`) use `invoke`. They turn server errors into a plain `Error` carrying the `HubException` message.
|
||||
- Streaming input (`input`, `selectTarget`) uses `send`, which is fire-and-forget. These calls are dropped while disconnected.
|
||||
|
||||
In development, React StrictMode mounts the hook twice. The first connection is stopped while negotiating, and the console logs `Failed to start the connection: … stopped during negotiation`. This is harmless and does not happen in production builds.
|
||||
|
||||
## The game screen (`src/pages/GamePage.tsx`)
|
||||
|
||||
Layout: **hold + stats + "1 YOU"**, then the **own board** with the garbage meter and the inventory bar below it, then the **next queue**. To the right is a wrapping grid of **opponent mini boards**.
|
||||
|
||||
- **Own entry.** `me = match.players.find(p => p.id === playerId)`. If it is missing, the player joined mid-match and the page shows a "SPECTATING" box plus all boards as minis.
|
||||
- **Sizes.** The own board's cell size comes from the window height: `clamp(16, (innerHeight - 170) / 20, 32)` px. Mini boards use 14/12/9 px for ≤ 2, ≤ 4 and more opponents.
|
||||
- **Target selection.**
|
||||
- The selected target lives in local state, `targetId`. Slot `1` is yourself and slot `n ≥ 2` is `opponents[n-2]`, in match order excluding yourself.
|
||||
- A key press updates the local state and calls `selectTarget` on the server.
|
||||
- If the target dies, an effect resets it to yourself, which mirrors the server rule. The selected board gets a cyan frame, and its slot number is filled cyan.
|
||||
- **Overlays on the own board:** `ItemFlashOverlay` (flashes whose `targetId` is you), the countdown number, and "YOU WIN!" or "<name> WINS".
|
||||
- **Blackout.** `hideStack={me.invisibleRemaining > 0}` makes `drawBoard` skip locked cells, item icons and the ghost.
|
||||
- **Garbage meter.** A red bar whose height is `pendingGarbage × cell`.
|
||||
- **Inventory bar.** 10 slots, with the oldest item outlined, plus the hint line `F: <next item> > <target>`.
|
||||
- **Mini boards.** `MiniBoard` is memoized. It shows the slot number, the name, the board, a flash overlay and the opponent's **item bar beneath the board**.
|
||||
|
||||
## Keyboard input (`src/input/useKeyboardInput.ts`)
|
||||
|
||||
- Bindings come from `config/keybindings.ts` and are matched on `KeyboardEvent.code` (physical keys).
|
||||
- Only active while `enabled` (the player is alive and the match is not finished). Key events coming from `<input>` and `<textarea>` are ignored.
|
||||
- **Left/right** sends one move immediately. After `DAS_MS` (167 ms) it repeats every `ARR_MS` (33 ms). Held directions form a stack, so releasing one resumes the other.
|
||||
- **Soft drop** counts held soft-drop keys. It sends `SoftDropStart` on the first press and `SoftDropStop` when the last one is released.
|
||||
- **Number keys** call `onTarget(slot)`.
|
||||
- Browser key-repeat events are ignored. `preventDefault` stops arrows and space from scrolling the page.
|
||||
- On window **blur** every held key is released, which also stops a running soft drop.
|
||||
|
||||
## Rendering (`src/render/draw.ts`)
|
||||
|
||||
All drawing is plain Canvas 2D in a pixel style:
|
||||
|
||||
- `setupCanvas` sizes the canvas for `devicePixelRatio` and disables image smoothing.
|
||||
- `drawBlock` draws one block: a base color with a lighter top/left edge and a darker bottom/right edge. **Change this function to change the block look everywhere.**
|
||||
- `drawItemIcon` draws the item's SVG (from `assets.ts`) over a cell. While the image is still loading it falls back to a colored square.
|
||||
- `drawBoard` draws, in order: background, grid lines (only when cells are ≥ 14 px), locked cells and item icons (skipped when `hideStack`), the ghost (semi-transparent outline), and the active piece with its item icon.
|
||||
- `drawPreviewPiece` draws hold and next pieces centered in a box, using the shapes from `render/shapes.ts`.
|
||||
|
||||
`BoardCanvas` redraws in an effect whenever its `state` prop changes, i.e. on every snapshot. It also renders the "OUT"/place overlay for eliminated players and any children as overlays.
|
||||
|
||||
## Theming and graphics
|
||||
|
||||
Everything visual can be swapped without touching component logic:
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| UI colors, font, border style | CSS variables in `src/theme/global.css` (`--color-*`, `--font-pixel`, `--border`) |
|
||||
| Block colors (1–7 pieces, 8 garbage), grid, ghost alpha | `src/theme/colors.ts` |
|
||||
| Item icons, names, flash colors, help texts | `itemVisuals` in `src/theme/assets.ts` |
|
||||
| Image files | `public/assets/…`, referenced only from `assets.ts` |
|
||||
| Logo | `uiAssets.logo` in `assets.ts`. `null` renders the colored text logo |
|
||||
| Block drawing style | `drawBlock` in `src/render/draw.ts` |
|
||||
| Font | Google Fonts link in `index.html` plus `--font-pixel` |
|
||||
|
||||
The pixel font (*Press Start 2P*) has **no arrow, ellipsis or dash glyphs**, so UI text uses ASCII only ("LEFT RIGHT", "...", "-", ">").
|
||||
|
||||
Asset paths go through the `asset()` helper in `assets.ts` (`import.meta.env.BASE_URL + path`), so they stay correct when the site is served under a sub-path.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
`src/config/runtimeConfig.ts` resolves the hub URL in this order:
|
||||
|
||||
1. `window.__ACTIONBLOCKS_CONFIG__.serverUrl` from `config.js`. In Docker this file is generated from `SERVER_URL` at container start.
|
||||
2. `VITE_SERVER_URL`, read at build time.
|
||||
3. `<page origin>/hub`.
|
||||
|
||||
## Sub-path support
|
||||
|
||||
The site can live at `https://host/` or at `https://host/some/path/`:
|
||||
|
||||
- `vite.config.ts` sets `base: './'`, so all built URLs are relative (`./static/…`, `./config.js`, `./assets/…`).
|
||||
- `index.html` starts with a tiny script that redirects `/path` to `/path/`. Without the trailing slash, relative URLs would resolve against the parent directory.
|
||||
- Hashed bundles go to `static/` (`build.assetsDir`), which keeps them separate from the exchangeable graphics in `assets/`.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Game Rules (as implemented)
|
||||
|
||||
This page describes the rules **exactly as the code implements them**, with every number. The code locations are given so you can verify or change each rule. Unless noted otherwise, files are in `actionBlocks_server/src/ActionBlocks.Game/`.
|
||||
|
||||
## Match flow
|
||||
|
||||
1. The host clicks **Start game** in the lobby. Every player currently in the lobby becomes part of the match.
|
||||
2. **Countdown** (`GameSettings.CountdownSeconds`, default 3 s). Boards are visible but no piece has spawned yet, and inputs are ignored. Target selection already works.
|
||||
3. **Running.** Everyone receives the **same piece sequence** (shared seed), so the game is fair. Item rolls are independent per player.
|
||||
4. A player is **eliminated** when they top out (see below) or disconnect.
|
||||
5. **Finished** when at most one player is alive. In a single-player practice round it ends when that player tops out.
|
||||
6. The result is shown for 4 seconds (`MatchRunner.ResultsDelay`). Then the lobby returns to the waiting state and shows a results table. The host can start the next round.
|
||||
|
||||
Players who join a lobby while a match is running watch as spectators. They take part from the next round on.
|
||||
|
||||
**Places:** a player eliminated while *n* players are still alive gets place *n + 1*. Players eliminated in the same tick share that place. The last survivor gets place 1 and is the winner.
|
||||
|
||||
## Playfield
|
||||
|
||||
| Rule | Value | Where |
|
||||
|---|---|---|
|
||||
| Width × visible height | 10 × 20 | `GameSettings.BoardWidth`, `VisibleRows` |
|
||||
| Hidden buffer above the field | 20 rows | `GameSettings.BufferRows` |
|
||||
| Next-piece preview | 5 | `GameSettings.PreviewCount` |
|
||||
|
||||
Pieces spawn in the two buffer rows directly above the visible field. They are horizontally centered, rounded to the left: columns 3–5 for 3-wide pieces, 3–6 for the long piece, 4–5 for the square. Every piece moves down one row immediately after spawning. See `PlayerGame.Spawn`.
|
||||
|
||||
## Pieces and randomizer
|
||||
|
||||
- There are 7 shapes. Each has 4 rotation states (0 = spawn, 1 = right, 2 = 180°, 3 = left). States are derived by rotating the spawn shape inside its bounding box (`Core/PieceDefinitions.cs`).
|
||||
- The **bag randomizer** shuffles all 7 shapes into a bag and deals them one by one, then refills (`Core/Randomizer.cs`). This guarantees every shape appears exactly once in every 7 pieces.
|
||||
|
||||
## Rotation (SRS)
|
||||
|
||||
Rotation uses the standard *Super Rotation System* kick tables (`Core/RotationSystem.cs`):
|
||||
|
||||
- When a rotation is blocked, up to 4 alternative offsets ("kicks") are tried in order.
|
||||
- The long piece has its own kick table. The square never kicks.
|
||||
- If no offset fits, the rotation does nothing.
|
||||
|
||||
## Movement, gravity and locking
|
||||
|
||||
| Rule | Value | Where |
|
||||
|---|---|---|
|
||||
| Gravity at level *L* | (0.8 − (L−1)·0.007)^(L−1) seconds per row. Level 1 = 1 s/row | `GameSettings.GravitySecondsPerRow` |
|
||||
| Soft drop | 20× gravity, at most 60 rows/s | `SoftDropFactor`, `MaxSoftDropRowsPerSecond` |
|
||||
| Lock delay | 0.5 s on the ground | `LockDelaySeconds` |
|
||||
| Lock resets | max 15 moves/rotations while grounded | `MaxLockResets` |
|
||||
| Level | 1 + lines ÷ 10, max 15 | `LinesPerLevel`, `MaxLevel` |
|
||||
|
||||
Lock-delay details (*move reset*, `PlayerGame.Update` / `RegisterLockReset`):
|
||||
|
||||
- While the piece rests on something, a timer runs. At 0.5 s the piece locks.
|
||||
- Every successful move or rotation while grounded resets the timer and uses up one of 15 resets. When the resets are exhausted, the piece locks the moment it is grounded.
|
||||
- When the piece reaches a row lower than ever before, both the timer and the reset counter start over.
|
||||
|
||||
**Hard drop** moves the piece straight down and locks it immediately.
|
||||
|
||||
**Hold** swaps the active piece with the hold slot. If the slot is empty, the next piece spawns instead. Hold can be used once per piece and becomes available again after the next lock. An item inside a held piece stays with it.
|
||||
|
||||
## Line clears, spins and scoring
|
||||
|
||||
When a piece locks (`PlayerGame.Lock`):
|
||||
|
||||
1. **Spin check.** The piece is a T, its last successful action was a rotation, and at least 3 of the 4 corners of its 3×3 box are blocked (walls count as blocked). It is a **full spin** if both corners on the pointing side are blocked, or if the last rotation used the 5th kick. Otherwise it is a **mini spin**.
|
||||
2. **Lock out.** If all four cells are inside the hidden buffer, the player tops out.
|
||||
3. Full rows are removed. **Items in those rows go to the player's inventory.**
|
||||
4. **Combo.** It counts consecutive locks that clear lines. It is −1 when there is no streak, 0 for the first clear, 1 for the second clear in a row, and so on.
|
||||
5. **Back-to-back.** It applies when the previous line clear was also "difficult" (4 lines, or a spin that clears lines) and this one is too.
|
||||
6. **Perfect clear** is when the board is completely empty after the clear.
|
||||
|
||||
**Score** (`Garbage/GarbageCalculator.cs`, `StandardScoreCalculator`) is multiplied by the current level:
|
||||
|
||||
| Clear | Points |
|
||||
|---|---|
|
||||
| 1 / 2 / 3 / 4 lines | 100 / 300 / 500 / 800 |
|
||||
| Spin with 0 / 1 / 2 / 3 lines | 400 / 800 / 1200 / 1600 |
|
||||
| Mini spin with 0 / 1 / 2 lines | 100 / 200 / 400 |
|
||||
| Back-to-back | ×1.5 on the above |
|
||||
| Combo | +50 × combo |
|
||||
| Soft drop | +1 per row (not multiplied) |
|
||||
| Hard drop | +2 per row (not multiplied) |
|
||||
|
||||
## Garbage (attacks)
|
||||
|
||||
**Attack size** (`StandardGarbageCalculator`):
|
||||
|
||||
| Clear | Lines sent |
|
||||
|---|---|
|
||||
| Single / Double / Triple / Quad | 0 / 1 / 2 / 4 |
|
||||
| Spin Single / Double / Triple | 2 / 4 / 6 |
|
||||
| Mini spin Single / Double | 0 / 1 |
|
||||
| Back-to-back bonus | +1 |
|
||||
| Combo bonus (by combo count 0, 1, 2, …) | 0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5 (capped) |
|
||||
| Perfect clear | +10 |
|
||||
|
||||
**Cancelling.** Outgoing lines first cancel the player's own pending incoming garbage. Only the rest is sent.
|
||||
|
||||
**Targeting.** Garbage is **not** sent to the selected item target. It goes round-robin through the living opponents, in match order: each sender has its own cursor, and dead players are skipped (`Targeting/GarbageTargeter.cs`).
|
||||
|
||||
**Arrival.** Incoming garbage waits in a queue (the red meter next to the board). It rises when the receiver next locks a piece **without** clearing a line. Each attack becomes a block of gray rows sharing one random hole column.
|
||||
|
||||
## Top out (elimination)
|
||||
|
||||
A player is eliminated when any of these happens:
|
||||
|
||||
- **Block out:** a new piece spawns overlapping existing blocks.
|
||||
- **Lock out:** a piece locks completely inside the hidden buffer.
|
||||
- **Garbage overflow:** garbage pushes a filled cell out of the top of the buffer.
|
||||
- **No room after an item:** an item changed the board under the active piece and the piece cannot be pushed up to a free spot.
|
||||
- **Disconnect:** the player leaves or closes the tab.
|
||||
|
||||
## Items
|
||||
|
||||
### Spawning and collecting
|
||||
|
||||
- Each **newly generated** piece has a 10% chance (`GameSettings.ItemSpawnChance`) to carry an item. The item sits in one random mino of the four. The roll happens when the piece enters the queue, so items are already visible in the preview and in hold (`Items/ItemRegistry.cs`, `ItemSpawner`).
|
||||
- When the piece locks, the item stays in that cell of the board. Item cells move with the board: they drop when lines below them clear, rise with garbage, and travel along when boards are swapped.
|
||||
- **Clearing the row that contains the item cell** moves the item into the player's inventory.
|
||||
- If the cell is destroyed another way (for example by *Clear Line* or *Wipe Out*), the item is lost.
|
||||
|
||||
### Inventory and use
|
||||
|
||||
- The inventory holds at most 10 items (`MaxInventory`). Items collected beyond that are discarded.
|
||||
- Items queue up first in, first out. **F** always uses the **oldest** item, the leftmost one in the bar.
|
||||
- **Number keys** select the target: `1` is yourself, `2`–`9` are the opponents in the order they are displayed.
|
||||
- Unknown or eliminated targets are ignored. If the current target gets eliminated, the selection falls back to yourself, on both server and client.
|
||||
- Using an item with an empty inventory does nothing.
|
||||
|
||||
### Visibility rules
|
||||
|
||||
- **Nobody sees who targets whom.** The selected target is never broadcast.
|
||||
- Everyone sees every opponent's inventory in the item bar beneath their mini board.
|
||||
- Every client is told when an item hits someone (`ItemUsedEvent`): which player was hit and which item, but not who used it. The item name flashes on top of the hit player's board. That is the big board for yourself, or the mini board for an opponent.
|
||||
|
||||
### The five items
|
||||
|
||||
| Id | Name | Effect | Class |
|
||||
|---|---|---|---|
|
||||
| `add_line` | Add Line | Immediately inserts 1 garbage row (random hole) at the bottom of the target's board. This can top the target out. | `AddGarbageLineItem` |
|
||||
| `clear_line` | Clear Line | Removes the bottom row of the target. Items in it are lost. | `ClearBottomLineItem` |
|
||||
| `nuke` | Wipe Out | Removes **all** locked blocks of the target. The active piece is unaffected. | `ClearBoardItem` |
|
||||
| `swap` | Switch | The user and the target exchange all locked blocks. Active pieces, hold, queues, inventories and pending garbage stay with their owners. If an active piece now overlaps, it is pushed up. Does nothing when used on yourself. | `SwapBoardItem` |
|
||||
| `blind` | Blackout | For 4 seconds, the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Re-applying extends the effect to *now + 4 s*. | `InvisibilityItem` |
|
||||
|
||||
Every item can also be used on yourself: target `1`. *Wipe Out* and *Clear Line* are useful defensively.
|
||||
|
||||
## Controls
|
||||
|
||||
Bindings use physical key positions (`KeyboardEvent.code`), so they work on any keyboard layout. They are defined in `actionBlocks_website/src/config/keybindings.ts`.
|
||||
|
||||
| Action | Keys |
|
||||
|---|---|
|
||||
| Move left / right | ← → or A D |
|
||||
| Soft drop (hold) | ↓ or S |
|
||||
| Hard drop | Space |
|
||||
| Rotate clockwise | ↑, W, X, E |
|
||||
| Rotate counter-clockwise | Z (also the key labeled Y on QWERTZ), Q, Ctrl |
|
||||
| Hold | C, Shift |
|
||||
| Select item target | 1–9 (1 = yourself) |
|
||||
| Use item | F |
|
||||
|
||||
Holding left/right repeats the move after 167 ms, then every 33 ms (DAS/ARR). When both directions are held, the most recently pressed one wins.
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Client ↔ Server Protocol
|
||||
|
||||
Communication uses **ASP.NET Core SignalR** over WebSockets. SignalR falls back to Server-Sent Events or long polling if WebSockets are unavailable. The payload format is JSON with **camelCase** property names. Enums are sent as **strings**.
|
||||
|
||||
- Hub URL: `<server>/hub`. The path is configurable through `HUB_PATH`, and the website finds the URL through `SERVER_URL` (see [Deployment](deployment.md)).
|
||||
- Server implementation: `actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs`
|
||||
- DTO definitions: `actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs`
|
||||
- Client implementation: `actionBlocks_website/src/net/hubClient.ts`
|
||||
- Client types (mirror of the DTOs): `actionBlocks_website/src/net/types.ts`
|
||||
|
||||
> When you change a DTO on the server, update `types.ts` in the website as well. Nothing generates it automatically.
|
||||
|
||||
## Client → server (hub methods)
|
||||
|
||||
| Method | Arguments | Returns | Notes |
|
||||
|---|---|---|---|
|
||||
| `CreateLobby` | `name: string` | `JoinResult` | Creates a lobby and joins it as host |
|
||||
| `JoinLobby` | `code: string, name: string` | `JoinResult` | The code is case-insensitive |
|
||||
| `LeaveLobby` | – | – | Also forfeits a running match |
|
||||
| `StartGame` | – | – | Host only; fails while a match is running |
|
||||
| `Input` | `action: InputAction` | – | Sent with `send` (fire-and-forget) |
|
||||
| `SelectTarget` | `targetId: string` | – | Player id of the item target, or your own id |
|
||||
| `GetItems` | – | `ItemInfo[]` | Lists all server items (id + name). Currently unused by the website |
|
||||
|
||||
`InputAction` is one of `MoveLeft`, `MoveRight`, `RotateCw`, `RotateCcw`, `SoftDropStart`, `SoftDropStop`, `HardDrop`, `Hold`, `UseItem`.
|
||||
|
||||
**Soft drop** is a state: the client sends `SoftDropStart` on key down and `SoftDropStop` on key up. Every other action is a single event. Left/right auto-repeat is generated by the client.
|
||||
|
||||
### Errors
|
||||
|
||||
Failed calls throw a `HubException`. The client receives its message, and `hubClient.ts` extracts the text after `HubException: `. Possible messages:
|
||||
|
||||
| Message | Cause |
|
||||
|---|---|
|
||||
| `You are already in a lobby.` | `CreateLobby` / `JoinLobby` while already joined |
|
||||
| `Lobby not found.` | Wrong code, or the lobby was deleted |
|
||||
| `Lobby is full.` | 8 players already |
|
||||
| `You are not in a lobby.` | `StartGame` without a lobby |
|
||||
| `Only the host can start the game.` | `StartGame` from a non-host |
|
||||
| `A game is already running.` | `StartGame` during a match |
|
||||
|
||||
`Input` and `SelectTarget` from a connection that is not in a lobby are silently ignored.
|
||||
|
||||
## Server → client messages
|
||||
|
||||
| Message | Payload | Sent when |
|
||||
|---|---|---|
|
||||
| `LobbyUpdated` | `Lobby` | Another player joined or left, the host changed, a match started (`phase: "playing"`) or a match ended and results are available (`phase: "waiting"`) |
|
||||
| `MatchState` | `MatchState` | During a match: ≈30 Hz, plus immediately after any input or event, and for 4 s after the match finished |
|
||||
|
||||
The player who calls `CreateLobby` / `JoinLobby` gets the lobby as the method's return value. Only the *other* players receive `LobbyUpdated` for that join.
|
||||
|
||||
## Lobby flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Host browser
|
||||
participant S as Server
|
||||
participant B as Guest browser
|
||||
|
||||
A->>S: CreateLobby("Alice")
|
||||
S-->>A: JoinResult { playerId, lobby{code:"MFHZVM"} }
|
||||
B->>S: JoinLobby("mfhzvm", "Bob")
|
||||
S-->>B: JoinResult
|
||||
S-->>A: LobbyUpdated (2 players)
|
||||
A->>S: StartGame()
|
||||
S-->>A: LobbyUpdated (phase: playing)
|
||||
S-->>B: LobbyUpdated (phase: playing)
|
||||
loop match running
|
||||
A->>S: Input / SelectTarget
|
||||
B->>S: Input / SelectTarget
|
||||
S-->>A: MatchState
|
||||
S-->>B: MatchState
|
||||
end
|
||||
Note over S: phase finished → 4 s results
|
||||
S-->>A: LobbyUpdated (phase: waiting, lastResults)
|
||||
S-->>B: LobbyUpdated (phase: waiting, lastResults)
|
||||
```
|
||||
|
||||
## Payloads
|
||||
|
||||
### `JoinResult`
|
||||
|
||||
```json
|
||||
{ "playerId": "ade86f694a81", "lobby": { /* Lobby */ } }
|
||||
```
|
||||
|
||||
`playerId` identifies *you* inside this lobby. The client needs it to find its own entry in `MatchState.players`.
|
||||
|
||||
### `Lobby`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "MFHZVM",
|
||||
"hostId": "ade86f694a81",
|
||||
"phase": "waiting", // "waiting" | "playing"
|
||||
"maxPlayers": 8,
|
||||
"players": [
|
||||
{ "id": "ade86f694a81", "name": "Alice", "isHost": true },
|
||||
{ "id": "3466ff69d433", "name": "Bob", "isHost": false }
|
||||
],
|
||||
"lastResults": [ // null before the first match
|
||||
{ "id": "ade86f694a81", "name": "Alice", "place": 1, "score": 1250, "lines": 12 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `MatchState`
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "running", // "countdown" | "running" | "finished"
|
||||
"countdown": 0, // seconds left in the countdown
|
||||
"winnerId": null, // set when finished
|
||||
"width": 10,
|
||||
"height": 20, // visible rows
|
||||
"players": [ /* PlayerState, in match order */ ],
|
||||
"events": [ /* MatchEvent, only those since the previous snapshot */ ]
|
||||
}
|
||||
```
|
||||
|
||||
### `PlayerState`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ade86f694a81",
|
||||
"name": "Alice",
|
||||
"alive": true,
|
||||
"place": null, // set when eliminated, and for the winner at the end
|
||||
"board": "0000000000…3330000000", // width*height chars, row by row, top to bottom
|
||||
"items": [ { "x": 2, "y": 19, "itemId": "nuke" } ],
|
||||
"active": {
|
||||
"type": 7,
|
||||
"cells": [[4,-1],[3,0],[4,0],[5,0]],
|
||||
"itemIndex": 2, // which of the 4 cells carries the item, -1 if none
|
||||
"itemId": "swap"
|
||||
},
|
||||
"ghost": [[4,17],[3,18],[4,18],[5,18]],
|
||||
"hold": { "type": 3, "itemId": null, "itemIndex": -1 },
|
||||
"holdUsed": false,
|
||||
"next": [ { "type": 4, "itemId": null, "itemIndex": -1 }, … ],
|
||||
"inventory": ["add_line", "blind"], // oldest first; inventory[0] is used by F
|
||||
"score": 1250,
|
||||
"lines": 12,
|
||||
"level": 2,
|
||||
"pendingGarbage": 3, // incoming lines waiting to rise
|
||||
"invisibleRemaining": 0 // seconds of Blackout left (> 0 = hide stack + ghost)
|
||||
}
|
||||
```
|
||||
|
||||
**Board encoding.** One character per cell: `'0'` is empty, `'1'`–`'7'` are piece colors (I, J, L, O, S, Z, T), `'8'` is garbage. Index = `y * width + x`, where y = 0 is the **top visible row**.
|
||||
|
||||
**Coordinates.** All `x, y` values are in visible coordinates. Active-piece cells can have **negative y** while the piece is still above the visible area. Clients skip those cells when drawing.
|
||||
|
||||
**Piece type numbers** are `PieceType` values: `1=I, 2=J, 3=L, 4=O, 5=S, 6=Z, 7=T`. They double as the color index in `theme/colors.ts`.
|
||||
|
||||
For eliminated players, `active` and `ghost` are `null`.
|
||||
|
||||
### `MatchEvent`
|
||||
|
||||
All events use one flat shape. Fields that don't apply are `null`.
|
||||
|
||||
| `type` | Fields | Meaning |
|
||||
|---|---|---|
|
||||
| `itemUsed` | `targetId`, `itemId`, `itemName` | An item hit `targetId`. **The user is intentionally not included.** |
|
||||
| `eliminated` | `playerId`, `place` | A player topped out or left |
|
||||
|
||||
The selected item **target is never part of any broadcast**, so players cannot tell who is targeting them.
|
||||
|
||||
## Writing a test client
|
||||
|
||||
Any SignalR client can talk to the server. A minimal Node.js example:
|
||||
|
||||
```js
|
||||
const signalR = require('@microsoft/signalr');
|
||||
const c = new signalR.HubConnectionBuilder().withUrl('http://localhost:5080/hub').build();
|
||||
c.on('LobbyUpdated', (lobby) => { console.log('lobby', lobby.phase); }); // handlers must not return a value
|
||||
c.on('MatchState', (state) => { /* inspect state.players / state.events */ });
|
||||
await c.start();
|
||||
const { playerId, lobby } = await c.invoke('JoinLobby', 'ABC123', 'Bot');
|
||||
c.send('Input', 'HardDrop');
|
||||
```
|
||||
|
||||
> SignalR's JavaScript client treats a handler's **return value** as a "client result". Write handlers as blocks (`(x) => { … }`), not expressions (`(x) => state = x`). Otherwise the client logs `Result given for '…' method but server is not expecting a result`.
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
# 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**.
|
||||
- `Board` has `Height = VisibleRows + BufferRows` rows (40 by default).
|
||||
- Row `0` is the top of the hidden buffer. The visible field is rows `BufferRows … 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/Y` is 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
|
||||
|
||||
```csharp
|
||||
readonly record struct Cell(byte Color, string? ItemId = null) // Color 0 = empty, 1-7 pieces, 8 garbage
|
||||
sealed record PieceSpec(PieceType Type, string? ItemId, int ItemMinoIndex) // what sits in the queue
|
||||
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:
|
||||
|
||||
```mermaid
|
||||
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. `UseItem` is ignored here, because using an item needs a target. `Match` handles 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 by `Match`.
|
||||
- **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 `PlayerGame` per player. All of them share one piece seed, so everyone gets the same sequence. Each player gets its own `Random` for items and garbage holes. The match starts in `Countdown`, or directly in `Running` when `CountdownSeconds == 0`, which tests use.
|
||||
- **`Update(dt)`** advances the countdown, or updates all players and then runs `AfterStateChange()`.
|
||||
- **`HandleInput(playerId, action)`** is ignored unless the match is running and the player is alive. `UseItem` is resolved here. Then `AfterStateChange()` runs.
|
||||
- **`SelectTarget(playerId, targetId)`** stores the target on the `PlayerGame`. An invalid or dead target becomes the player themself.
|
||||
- **`Forfeit(playerId)`** is used when a player disconnects.
|
||||
- **`AfterStateChange()`** routes every outgoing attack through the `IGarbageTargeter`, 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)` and `PlayerEliminatedEvent(PlayerId, Place)` are collected and drained once per tick by the host.
|
||||
|
||||
### Items
|
||||
|
||||
```csharp
|
||||
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.Default` lists all active items. `ItemRegistry.PickRandom` chooses an item weighted by `SpawnWeight`.
|
||||
- `ItemSpawner.Decorate(type)` turns a raw piece type into a `PieceSpec`, sometimes with an item.
|
||||
|
||||
See [Extending → Add an item](extending.md#add-a-new-item).
|
||||
|
||||
### `GameSettings`
|
||||
|
||||
`GameSettings` is an immutable record with `init` properties, one per tunable number. See [Game rules](game-rules.md) 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_BASE` and the `Game` section.
|
||||
- **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`, so `InputAction` travels as a string like `"HardDrop"`.
|
||||
- **Singletons:** `GameSettings` and `LobbyManager`. Hosted service: `MatchRunner`.
|
||||
- **Endpoints:** `GET /health` returns `{"status":"ok"}`, and the hub is mapped at `HUB_PATH`.
|
||||
- `UsePathBase(PATH_BASE)` lets the app accept requests that still carry a proxy prefix. Requests without the prefix work too.
|
||||
|
||||
### `LobbyManager`
|
||||
|
||||
- `_lobbies` maps lobby codes to `Lobby` objects. `_connectionLobby` maps 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.
|
||||
- `Leave` removes the player, and deletes the lobby once it is empty.
|
||||
- Failures throw a `LobbyException`. The hub turns it into a `HubException` whose message reaches the client.
|
||||
|
||||
### `Lobby`
|
||||
|
||||
- `Players` and `HostId` are tracked; the first player is the host. If the host leaves, the next player becomes host.
|
||||
- `MaxPlayers = 8`.
|
||||
- `StartMatch(settings)` creates a `Match` from the current players. `EndMatch()` stores `LastResults` and clears `Match`.
|
||||
- `Dirty` is set by input so that the next tick broadcasts immediately.
|
||||
- `Remove(player)` also forfeits the player in a running match.
|
||||
- `ToDto()` builds the `LobbyDto`.
|
||||
|
||||
### `GameHub`
|
||||
|
||||
The client API. It is documented in detail in [Protocol](protocol.md). 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:
|
||||
|
||||
1. Measures the real elapsed time and clamps it to at most 0.1 s, so a stall doesn't cause huge jumps.
|
||||
2. For each lobby with a match, inside the lock: `match.Update(delta)`, then `DrainEvents()`.
|
||||
3. 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.
|
||||
4. Once the match is finished, it waits `ResultsDelay` (4 s), then calls `lobby.EndMatch()` and broadcasts a `LobbyUpdated`.
|
||||
5. 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](protocol.md).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user