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 `