185 lines
7.6 KiB
Markdown
185 lines
7.6 KiB
Markdown
# 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`.
|