{"asyncapi":"3.0.0","info":{"title":"Khelau Call Break game socket","version":"1.0.0","description":"Real-time play for **Call Break** on Khelau. One WebSocket carries the whole session: identify, join or create a room, then bid and play. The server is authoritative: it validates every move, keeps hidden cards server-side and sends each player only their own view.\n\n## Connection\n- **URL**: `wss://khelau-api.vercel.app/api/ws` (production), `ws://localhost:8787/api/ws` (local). Also served at `/ws` and `/v1/callbreak/ws`.\n- **Origin**: browsers must connect from an origin listed in the API's `ALLOWED_ORIGINS`; others are refused with HTTP 403.\n- **Frames**: UTF-8 JSON objects with a `t` (type) field, at most 4 KB each. Malformed or unknown messages get an `error`.\n- **Limits**: about 20 messages per second per socket (bursts of 40); connections and room creation are rate-limited per IP.\n\n## Session lifecycle\n1. Connect and send `hello` (with the stored `token`, if any). Receive `welcome`.\n2. `quick`, `create` or `join` a room. Receive `room` updates.\n3. In a game, receive `state` after every change and send `bid`/`play` on your turn, echoing `seq`.\n\n## Reconnecting\nConnections can drop at any time; Vercel also closes each socket after 5 minutes. Reconnect with backoff and send `hello` with the same `token`: you get your seat, hand and timers back. If the server that hosted the game went away, the game is restored from the database.","contact":{"name":"Khelau maintainers","url":"https://github.com/yalaitsolutions/khelau"},"license":{"name":"Proprietary. All rights reserved."},"externalDocs":{"description":"HTTP API (OpenAPI)","url":"https://khelau-api.vercel.app/docs"},"tags":[{"name":"client-to-server","description":"Messages clients send; the server receives them."},{"name":"server-to-client","description":"Messages the server sends to clients."}]},"defaultContentType":"application/json","servers":{"production":{"host":"khelau-api.vercel.app","protocol":"wss","pathname":"/api/ws","description":"Production"},"local":{"host":"localhost:8787","protocol":"ws","pathname":"/api/ws","description":"Local development (pnpm dev:api)"}},"channels":{"callbreak":{"address":"/api/ws","title":"Call Break game socket","description":"The single bidirectional channel for a player session.","messages":{"client.hello":{"$ref":"#/components/messages/client.hello"},"client.quick":{"$ref":"#/components/messages/client.quick"},"client.create":{"$ref":"#/components/messages/client.create"},"client.join":{"$ref":"#/components/messages/client.join"},"client.founders":{"$ref":"#/components/messages/client.founders"},"client.sitOut":{"$ref":"#/components/messages/client.sitOut"},"client.sitIn":{"$ref":"#/components/messages/client.sitIn"},"client.start":{"$ref":"#/components/messages/client.start"},"client.bid":{"$ref":"#/components/messages/client.bid"},"client.play":{"$ref":"#/components/messages/client.play"},"client.ready":{"$ref":"#/components/messages/client.ready"},"client.again":{"$ref":"#/components/messages/client.again"},"client.leave":{"$ref":"#/components/messages/client.leave"},"client.rejoin":{"$ref":"#/components/messages/client.rejoin"},"client.ping":{"$ref":"#/components/messages/client.ping"},"server.welcome":{"$ref":"#/components/messages/server.welcome"},"server.room":{"$ref":"#/components/messages/server.room"},"server.state":{"$ref":"#/components/messages/server.state"},"server.rejoin":{"$ref":"#/components/messages/server.rejoin"},"server.watch":{"$ref":"#/components/messages/server.watch"},"server.error":{"$ref":"#/components/messages/server.error"},"server.pong":{"$ref":"#/components/messages/server.pong"}}}},"operations":{"onClientHello":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Hello","summary":"Identify the player. Must be the first message on every connection.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.hello"}]},"onClientQuick":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Find a game","summary":"Join the public lobby with the most players waiting, or open a new one.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.quick"}]},"onClientCreate":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Create private room","summary":"Open a private room; its code is in the next `room` message.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.create"}]},"onClientJoin":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Join room","summary":"Join a room by code.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.join"}]},"onClientFounders":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Join Founders lobby","summary":"Join the invite-only Founders lobby.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.founders"}]},"onClientSitOut":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Sit out","summary":"Founders lobby: move from a seat to the bench (before a game).","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.sitOut"}]},"onClientSitIn":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Take a seat","summary":"Founders lobby: move from the bench to a free seat.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.sitIn"}]},"onClientStart":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Start","summary":"Start now; bots fill empty seats. Private rooms: host only.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.start"}]},"onClientBid":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Bid","summary":"Bid for the round when it is your turn.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.bid"}]},"onClientPlay":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Play card","summary":"Play a card from your hand when it is your turn. It must be one of `state.view.legal`.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.play"}]},"onClientReady":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Next round","summary":"Ready for the next round (between rounds).","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.ready"}]},"onClientAgain":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Play again","summary":"Start a new game in the same room after game over (host only).","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.again"}]},"onClientLeave":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Leave","summary":"Leave the room. Mid-game, a bot takes the seat and it is held for you for 2 minutes (see `rejoin`).","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.leave"}]},"onClientRejoin":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Rejoin","summary":"Take back the seat you left, while the rejoin offer is open.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.rejoin"}]},"onClientPing":{"action":"receive","channel":{"$ref":"#/channels/callbreak"},"title":"Ping","summary":"Keep-alive; answered with `pong`.","tags":[{"name":"client-to-server"}],"messages":[{"$ref":"#/channels/callbreak/messages/client.ping"}]},"sendServerWelcome":{"action":"send","channel":{"$ref":"#/channels/callbreak"},"title":"Welcome","summary":"Reply to `hello`. Store `token` (e.g. in localStorage) and send it in every later `hello`.","tags":[{"name":"server-to-client"}],"messages":[{"$ref":"#/channels/callbreak/messages/server.welcome"}]},"sendServerRoom":{"action":"send","channel":{"$ref":"#/channels/callbreak"},"title":"Room","summary":"The room you are in: seats, host, status and countdowns. `null` after leaving.","tags":[{"name":"server-to-client"}],"messages":[{"$ref":"#/channels/callbreak/messages/server.room"}]},"sendServerState":{"action":"send","channel":{"$ref":"#/channels/callbreak"},"title":"Game state","summary":"Your view of the game. It contains only your own hand; other players' cards are never sent.","tags":[{"name":"server-to-client"}],"messages":[{"$ref":"#/channels/callbreak/messages/server.state"}]},"sendServerRejoin":{"action":"send","channel":{"$ref":"#/channels/callbreak"},"title":"Rejoin offer","summary":"After leaving a game in progress: your seat is held for `offer.ms` more milliseconds. `null`: nothing to rejoin.","tags":[{"name":"server-to-client"}],"messages":[{"$ref":"#/channels/callbreak/messages/server.rejoin"}]},"sendServerWatch":{"action":"send","channel":{"$ref":"#/channels/callbreak"},"title":"Watch","summary":"Founders lobby: public progress for the founder sitting out (bids, tricks, totals; never cards).","tags":[{"name":"server-to-client"}],"messages":[{"$ref":"#/channels/callbreak/messages/server.watch"}]},"sendServerError":{"action":"send","channel":{"$ref":"#/channels/callbreak"},"title":"Error","summary":"A request was refused (e.g. room full, not your turn, illegal card). Human-readable.","tags":[{"name":"server-to-client"}],"messages":[{"$ref":"#/channels/callbreak/messages/server.error"}]},"sendServerPong":{"action":"send","channel":{"$ref":"#/channels/callbreak"},"title":"Pong","summary":"Reply to `ping`, with the number of players online on this server instance.","tags":[{"name":"server-to-client"}],"messages":[{"$ref":"#/channels/callbreak/messages/server.pong"}]}},"components":{"messages":{"client.hello":{"name":"hello","title":"Hello","summary":"Identify the player. Must be the first message on every connection.","description":"Send the stored session `token` to return to your seat after a refresh, a dropped connection or a server restart; send `null` the first time. The server answers with `welcome`, then `room` (and `state` when at a table).","contentType":"application/json","payload":{"type":"object","required":["t","v","token","name","avatar"],"properties":{"t":{"type":"string","const":"hello"},"v":{"type":"integer","const":1,"description":"Protocol version."},"token":{"type":["string","null"],"maxLength":128,"description":"Session token from a previous `welcome`, or null."},"name":{"type":"string","description":"Display name; trimmed and cut to 16 characters."},"avatar":{"type":"integer","minimum":0,"maximum":18,"description":"Avatar number. Photos never leave the device."},"clientId":{"type":"string","pattern":"^[A-Za-z0-9_-]{8,40}$","description":"Anonymous per-browser id, used for statistics only."}}},"examples":[{"name":"hello","payload":{"t":"hello","v":1,"token":null,"name":"Asha","avatar":10,"clientId":"k3v9x0q2m1"}}]},"client.quick":{"name":"quick","title":"Find a game","summary":"Join the public lobby with the most players waiting, or open a new one.","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"quick"}}},"examples":[{"name":"quick","payload":{"t":"quick"}}]},"client.create":{"name":"create","title":"Create private room","summary":"Open a private room; its code is in the next `room` message.","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"create"}}},"examples":[{"name":"create","payload":{"t":"create"}}]},"client.join":{"name":"join","title":"Join room","summary":"Join a room by code.","contentType":"application/json","payload":{"type":"object","required":["t","code"],"properties":{"t":{"type":"string","const":"join"},"code":{"type":"string","maxLength":16,"description":"The 5-character room code (case-insensitive)."}}},"examples":[{"name":"join","payload":{"t":"join","code":"KTM42"}}]},"client.founders":{"name":"founders","title":"Join Founders lobby","summary":"Join the invite-only Founders lobby.","description":"The password is checked only on the server. 5 wrong tries per 10 minutes.","contentType":"application/json","payload":{"type":"object","required":["t","name","password"],"properties":{"t":{"type":"string","const":"founders"},"name":{"type":"string","maxLength":64},"password":{"type":"string","maxLength":256,"format":"password"}}},"examples":[{"name":"founders","payload":{"t":"founders","name":"Sabin","password":"********"}}]},"client.sitOut":{"name":"sitOut","title":"Sit out","summary":"Founders lobby: move from a seat to the bench (before a game).","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"sitOut"}}},"examples":[{"name":"sitOut","payload":{"t":"sitOut"}}]},"client.sitIn":{"name":"sitIn","title":"Take a seat","summary":"Founders lobby: move from the bench to a free seat.","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"sitIn"}}},"examples":[{"name":"sitIn","payload":{"t":"sitIn"}}]},"client.start":{"name":"start","title":"Start","summary":"Start now; bots fill empty seats. Private rooms: host only.","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"start"}}},"examples":[{"name":"start","payload":{"t":"start"}}]},"client.bid":{"name":"bid","title":"Bid","summary":"Bid for the round when it is your turn.","contentType":"application/json","payload":{"type":"object","required":["t","bid"],"properties":{"t":{"type":"string","const":"bid"},"bid":{"type":"integer","minimum":1,"maximum":13},"seq":{"type":"integer","minimum":0,"description":"The `seq` of the latest `state` this client saw. A move made against an older state is ignored and the current `state` is sent again. Optional for older clients."}}},"examples":[{"name":"bid","payload":{"t":"bid","bid":3,"seq":12}}]},"client.play":{"name":"play","title":"Play card","summary":"Play a card from your hand when it is your turn. It must be one of `state.view.legal`.","contentType":"application/json","payload":{"type":"object","required":["t","card"],"properties":{"t":{"type":"string","const":"play"},"card":{"$ref":"#/components/schemas/Card"},"seq":{"type":"integer","minimum":0,"description":"The `seq` of the latest `state` this client saw. A move made against an older state is ignored and the current `state` is sent again. Optional for older clients."}}},"examples":[{"name":"play","payload":{"t":"play","card":{"suit":"S","rank":14},"seq":13}}]},"client.ready":{"name":"ready","title":"Next round","summary":"Ready for the next round (between rounds).","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"ready"}}},"examples":[{"name":"ready","payload":{"t":"ready"}}]},"client.again":{"name":"again","title":"Play again","summary":"Start a new game in the same room after game over (host only).","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"again"}}},"examples":[{"name":"again","payload":{"t":"again"}}]},"client.leave":{"name":"leave","title":"Leave","summary":"Leave the room. Mid-game, a bot takes the seat and it is held for you for 2 minutes (see `rejoin`).","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"leave"}}},"examples":[{"name":"leave","payload":{"t":"leave"}}]},"client.rejoin":{"name":"rejoin","title":"Rejoin","summary":"Take back the seat you left, while the rejoin offer is open.","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"rejoin"}}},"examples":[{"name":"rejoin","payload":{"t":"rejoin"}}]},"client.ping":{"name":"ping","title":"Ping","summary":"Keep-alive; answered with `pong`.","contentType":"application/json","payload":{"type":"object","required":["t"],"properties":{"t":{"type":"string","const":"ping"}}},"examples":[{"name":"ping","payload":{"t":"ping"}}]},"server.welcome":{"name":"welcome","title":"Welcome","summary":"Reply to `hello`. Store `token` (e.g. in localStorage) and send it in every later `hello`.","contentType":"application/json","payload":{"type":"object","required":["t","token","playerId"],"properties":{"t":{"type":"string","const":"welcome"},"token":{"type":"string","description":"Session token: it identifies your seat. Treat it as a secret."},"playerId":{"type":"string"}}},"examples":[{"name":"welcome","payload":{"t":"welcome","token":"q1w2e3r4t5y6u7i8o9p0as","playerId":"5f2a9c0d1e3b"}}]},"server.room":{"name":"room","title":"Room","summary":"The room you are in: seats, host, status and countdowns. `null` after leaving.","contentType":"application/json","payload":{"type":"object","required":["t","room"],"properties":{"t":{"type":"string","const":"room"},"room":{"oneOf":[{"$ref":"#/components/schemas/RoomInfo"},{"type":"null"}]}}},"examples":[{"name":"room","payload":{"t":"room","room":{"code":"KTM42","isPublic":false,"status":"lobby","hostSeat":0,"you":0,"seats":[{"name":"Asha","avatar":10,"kind":"human","connected":true,"botControlled":false},null,null,null],"autoStartMs":null,"founders":false,"bench":null}}}]},"server.state":{"name":"state","title":"Game state","summary":"Your view of the game. It contains only your own hand; other players' cards are never sent.","contentType":"application/json","payload":{"type":"object","required":["t","view","turnMsLeft","ready","nextRoundMs"],"properties":{"t":{"type":"string","const":"state"},"view":{"$ref":"#/components/schemas/PlayerView"},"turnMsLeft":{"type":["integer","null"],"description":"Milliseconds left for the player whose turn it is."},"ready":{"type":"array","items":{"type":"boolean"},"minItems":4,"maxItems":4,"description":"Between rounds: which seats pressed Next round."},"nextRoundMs":{"type":["integer","null"],"description":"Milliseconds until the next round starts automatically."},"seq":{"type":"integer","minimum":0,"description":"Action counter; echo it in `bid` and `play`."}}},"examples":[{"name":"state","payload":{"t":"state","view":{"seat":0,"round":1,"phase":"bidding","turn":0,"hand":[{"suit":"S","rank":14}],"bids":[null,3,null,null],"totals":[0,0,0,0],"legal":[]},"turnMsLeft":29000,"ready":[false,false,false,false],"nextRoundMs":null,"seq":12}}]},"server.rejoin":{"name":"rejoin","title":"Rejoin offer","summary":"After leaving a game in progress: your seat is held for `offer.ms` more milliseconds. `null`: nothing to rejoin.","contentType":"application/json","payload":{"type":"object","required":["t","offer"],"properties":{"t":{"type":"string","const":"rejoin"},"offer":{"type":["object","null"],"required":["code","ms"],"properties":{"code":{"type":"string"},"ms":{"type":"integer","minimum":0}}}}},"examples":[{"name":"rejoin","payload":{"t":"rejoin","offer":{"code":"KTM42","ms":118000}}}]},"server.watch":{"name":"watch","title":"Watch","summary":"Founders lobby: public progress for the founder sitting out (bids, tricks, totals; never cards).","contentType":"application/json","payload":{"type":"object","required":["t","watch"],"properties":{"t":{"type":"string","const":"watch"},"watch":{"type":["object","null"]}}},"examples":[{"name":"watch","payload":{"t":"watch","watch":null}}]},"server.error":{"name":"error","title":"Error","summary":"A request was refused (e.g. room full, not your turn, illegal card). Human-readable.","contentType":"application/json","payload":{"type":"object","required":["t","message"],"properties":{"t":{"type":"string","const":"error"},"message":{"type":"string"}}},"examples":[{"name":"error","payload":{"t":"error","message":"That room is full or already playing"}}]},"server.pong":{"name":"pong","title":"Pong","summary":"Reply to `ping`, with the number of players online on this server instance.","contentType":"application/json","payload":{"type":"object","required":["t","online"],"properties":{"t":{"type":"string","const":"pong"},"online":{"type":"integer","minimum":0}}},"examples":[{"name":"pong","payload":{"t":"pong","online":3}}]}},"schemas":{"Card":{"type":"object","required":["suit","rank"],"properties":{"suit":{"type":"string","enum":["S","H","C","D"],"description":"Spades (always trump), Hearts, Clubs, Diamonds."},"rank":{"type":"integer","minimum":2,"maximum":14,"description":"11 = J, 12 = Q, 13 = K, 14 = A."}}},"Seat":{"type":["object","null"],"required":["name","avatar","kind","connected","botControlled"],"properties":{"name":{"type":"string"},"avatar":{"type":"integer"},"kind":{"type":"string","enum":["human","bot"]},"connected":{"type":"boolean"},"botControlled":{"type":"boolean","description":"A human seat played by a bot for now (disconnected or left)."},"left":{"type":"boolean"},"awayMs":{"type":["integer","null"],"description":"Milliseconds left before a held seat is given up."}}},"RoomInfo":{"type":"object","required":["code","isPublic","status","hostSeat","you","seats","autoStartMs","founders","bench"],"properties":{"code":{"type":"string"},"isPublic":{"type":"boolean"},"status":{"type":"string","enum":["lobby","playing","finished"]},"hostSeat":{"type":"integer","minimum":0,"maximum":3},"you":{"type":"integer","minimum":0,"maximum":4,"description":"Your seat (4 = the Founders bench)."},"seats":{"type":"array","minItems":4,"maxItems":4,"items":{"$ref":"#/components/schemas/Seat"}},"autoStartMs":{"type":["integer","null"],"description":"Public lobbies: milliseconds until bots fill the seats and the game starts."},"founders":{"type":"boolean"},"bench":{"$ref":"#/components/schemas/Seat"}}},"PlayerView":{"type":"object","description":"Everything one seat may know. Other hands appear only as counts.","required":["seat","round","phase","turn","hand","bids","totals","legal"],"properties":{"seat":{"type":"integer","minimum":0,"maximum":3},"round":{"type":"integer","minimum":1},"dealer":{"type":"integer"},"phase":{"type":"string","enum":["bidding","playing","trickEnd","roundEnd","gameOver"]},"hand":{"type":"array","items":{"$ref":"#/components/schemas/Card"},"description":"Your cards only."},"handCounts":{"type":"array","items":{"type":"integer"},"description":"Cards left per seat."},"bids":{"type":"array","items":{"type":["integer","null"]}},"won":{"type":"array","items":{"type":"integer"},"description":"Tricks won this round, per seat."},"turn":{"type":"integer"},"trick":{"type":"array","description":"Cards on the table: `{seat, card}`."},"totals":{"type":"array","items":{"type":"integer"},"description":"Scores in tenths (41 = +4.1)."},"legal":{"type":"array","items":{"$ref":"#/components/schemas/Card"},"description":"Cards you may play now (empty unless it is your turn to play)."}}}}}}