Files
ActionBlock/doc/protocol.md
T
2026-10-23 20:11:23 +02:00

7.6 KiB
Raw Blame History

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).
  • 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

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

{ "playerId": "ade86f694a81", "lobby": { /* Lobby */ } }

playerId identifies you inside this lobby. The client needs it to find its own entry in MatchState.players.

Lobby

{
  "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

{
  "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

{
  "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:

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.