From 498df869625a5e8abb8a9b951514900e84763d06 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 23 Oct 2026 20:11:23 +0200 Subject: [PATCH] added documentation --- README.md | 2 + doc/README.md | 63 ++++++++++++++ doc/architecture.md | 126 ++++++++++++++++++++++++++++ doc/deployment.md | 129 +++++++++++++++++++++++++++++ doc/development.md | 88 ++++++++++++++++++++ doc/extending.md | 181 ++++++++++++++++++++++++++++++++++++++++ doc/frontend.md | 151 +++++++++++++++++++++++++++++++++ doc/game-rules.md | 163 ++++++++++++++++++++++++++++++++++++ doc/protocol.md | 184 ++++++++++++++++++++++++++++++++++++++++ doc/server.md | 198 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1285 insertions(+) create mode 100644 doc/README.md create mode 100644 doc/architecture.md create mode 100644 doc/deployment.md create mode 100644 doc/development.md create mode 100644 doc/extending.md create mode 100644 doc/frontend.md create mode 100644 doc/game-rules.md create mode 100644 doc/protocol.md create mode 100644 doc/server.md diff --git a/README.md b/README.md index 34b4884..b38eb77 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Online player-vs-player falling-block game with items. +**Developer documentation:** see [`doc/`](doc/README.md) for architecture, game rules, protocol, frontend, deployment and how-to guides. + | Component | Path | Tech | |---|---|---| | Game server (logic, lobbies, players) | `actionBlocks_server/` | ASP.NET Core 10, SignalR | diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..5f5045d --- /dev/null +++ b/doc/README.md @@ -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. diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..411cdb1 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,126 @@ +# Architecture + +## Overview + +```mermaid +flowchart LR + subgraph Browser + UI[React app
pages + canvas rendering] + KB[Keyboard input
DAS / ARR] + HC[HubClient
@microsoft/signalr] + KB --> HC + HC --> UI + end + + subgraph Proxy[Reverse proxy - existing] + end + + subgraph WebsiteContainer[actionblocks-website - nginx] + Static[index.html, JS, CSS,
assets, config.js] + end + + subgraph ServerContainer[actionblocks-server - ASP.NET Core] + Hub[GameHub] + LM[LobbyManager] + MR[MatchRunner
60 Hz loop] + GL[ActionBlocks.Game
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 | diff --git a/doc/deployment.md b/doc/deployment.md new file mode 100644 index 0000000..454dd3c --- /dev/null +++ b/doc/deployment.md @@ -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
nginx, static files] + P -->|network actionBlocks_server
WebSocket| S[actionblocks-server :8080
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 → `/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__` | 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:////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 `/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 | diff --git a/doc/development.md b/doc/development.md new file mode 100644 index 0000000..34bc2ad --- /dev/null +++ b/doc/development.md @@ -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 `/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 "/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. diff --git a/doc/extending.md b/doc/extending.md new file mode 100644 index 0000000..002be95 --- /dev/null +++ b/doc/extending.md @@ -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__` 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`. diff --git a/doc/frontend.md b/doc/frontend.md new file mode 100644 index 0000000..daa3879 --- /dev/null +++ b/doc/frontend.md @@ -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 ``. + +``` +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.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 " 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: > `. +- **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 `` and `