added documentation
This commit is contained in:
+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`.
|
||||
Reference in New Issue
Block a user