The room stream pushes round events to the client. Open it for the room the player is looking at: GET /g/{game}/rooms/{roomId}/stream, where {game} is lotto or thai-hilo.
Connecting
- URL: GET /gapi/v1/g/{game}/rooms/{roomId}/stream?token={token}¤cy={currency}, where {game} is
lotto or thai-hilo. Example: new EventSource(/gapi/v1/g/${game}/rooms/${roomId}/stream?token=${encodeURIComponent(token)}).
- EventSource cannot set headers, so pass the session token as
?token= (URL-encoded). The server reads Authorization: Bearer first and ?token= only when that header is absent, so a fetch()-based SSE client may send the header instead. Session checks are the same as on every other game route.
roomId is the room number (rooms[].id from GET /gapi/v1/g/thai-hilo/rooms, or roomId from the Lotto room list), not the room code.
currency is optional and defaults to the session currency.
- Which rooms emit events: THAI-HILO rooms in MULTI or MULTI_CSEED mode, and every LOTTO room. A THAI-HILO SINGLE room (or a room number that does not exist) still opens a stream, but it stays silent.
- One channel per game + room + currency + demo/real. Demo and real players never see each other's events, and a different currency is a different channel. To change room or currency, close the EventSource and open a new one.
- Nothing is sent on connect: no snapshot and no replay of earlier events. Call Join Round (GET …/rooms/{roomId}/round) as soon as the stream opens, and again after every reconnect.
- Rate limit GAME_ROOM_STREAM: 60 connects per 60 s per client IP, and every reconnect counts. Players behind one NAT share the budget.
- Response headers:
Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no.
- Any failure before streaming starts (401, 404, 400, 429, 503, 500) is a normal JSON error response with a non-200 status, not an SSE frame.
- The session is checked only at connect; an open stream keeps delivering after the token expires. The server sets no maximum lifetime on a stream.
- Every frame is a single
data: <json> line followed by a blank line, where is {"type": "<event name>", "data": {…event payload…}}.
- There are NO
event:, id: or retry: lines. Every frame arrives on EventSource onmessage (event type "message"): JSON.parse(e.data) and switch on .type. addEventListener('round_started', …) never fires.
- Key order is not guaranteed: the outer frame arrives as
data then type, and round_settled data keys arrive alphabetically (gameResult, playerResult, roundID, seedRevealed). Read by key, never by position.
- The round key inside events is
roundID (capital ID), as on the room endpoints; the LOTTO-only endpoints spell it roundId.
- There is no heartbeat, ping or comment line. A quiet room sends zero bytes: a LOTTO room with no bets can be silent for a whole round (15 minutes by default). Proxies or CDNs with idle timeouts (around 100 s) may cut such a connection, which makes EventSource reconnect.
const es = new EventSource(`${base}/g/${game}/rooms/${roomId}/stream?token=${encodeURIComponent(token)}`); // game: 'lotto' | 'thai-hilo'
es.onopen = () => loadCurrentRound(); // nothing is replayed — always re-read the round
es.onmessage = (e) => {
const { type, data } = JSON.parse(e.data);
switch (type) {
case 'round_started': /* Thai Hilo */ break;
case 'bet_placed': /* Thai Hilo */ break;
case 'player_joined': /* Thai Hilo */ break;
case 'seed_slot_updated': /* both */ break;
case 'round_settled': /* both */ break;
}
};
Round lifecycle
| Status | Meaning |
|---|
waiting | No betting or rolling round exists for this game + room + currency + demo/real. Only Join Round returns it (never stored). Seen before the server has opened a round, while the game is under maintenance, or between a cancelled round and its replacement (opened within about 60 s). |
betting | Open for bets until the server closes the round (up to ~2 s after endTime). Seen in Join Round and …/result. |
rolling | Betting closed, settlement and payouts in progress. Seen in Join Round and …/result. A round stuck here for more than 60 s is picked up by the server's recovery. |
settled | Seed revealed and payouts done, also for rounds with zero bets. Seen in …/result. |
cancelled | Settlement failed; stakes refunded (or the refund queued). Seen in …/result; no SSE event is sent and a replacement round is opened. |
Timing
- Round length is the room's
roundDurationSec; when it is 0 the server uses 30 s for THAI-HILO and 900 s for LOTTO.
- The server checks every 2 s and closes a
betting round (to rolling) once its endTime has passed, so the real close lags endTime by up to ~2 s plus processing. Betting is accepted on status only, so close the betting UI locally at endTime.
- The next round opens at the same moment the previous one closes (THAI-HILO then sends
round_started), so the next round is usually open before the previous one is settled. For a short time Join Round may answer either round.
- When settlement finishes,
round_settled is sent (only if at least one player bet) and the round is then marked settled. A …/result call made the instant the event arrives can still show rolling; retry briefly.
- If settlement fails, stakes are refunded, the round becomes
cancelled and a replacement round opens. No event is sent for this.
- Join Round
endTime is RFC3339 UTC with whole seconds (e.g. 2026-09-30T11:45:30Z). SSE round_started endTime is RFC3339 with nanoseconds in the server's local time zone offset (e.g. 2026-09-30T18:45:30.123456789+07:00, or …Z). Parse both as dates; never compare them as strings.
- No response or event carries the server time; the countdown depends on the client clock.
- The
rolling phase has no end timestamp: wait for round_settled (and, for THAI-HILO, round_started), or call Join Round again.
Reconnecting
- The server sends no
retry: and no id:, so the browser uses its default reconnect delay (about 3 s) and sends no useful Last-Event-ID.
- Delivery is at most once: events published while the client is disconnected are lost. Each connection buffers up to 64 events; beyond that events are dropped without notice.
- A non-200 answer at (re)connect (401 expired session, 429 rate limit, 503 maintenance, 404 bad
gameCode, 400 missing currency) closes the EventSource for good; the browser does not retry.
- On
onopen: call Join Round (GET …/round), and GET …/result for any round the player bet in and has not yet seen settled.
- On
onerror with readyState === CLOSED: refresh the session or back off, then create a new EventSource.
Events
| Event | Games | When |
|---|
round_started | THAI-HILO | The server opened a new betting round for this room + currency + demo/real: at start-up, at the moment the previous round closes, in the periodic recovery sweep, as a replacement after a cancelled round, or when recovering a stuck round. |
player_joined | THAI-HILO | Any player's Join Round call (GET …/rooms/{roomId}/round) finds a betting or rolling round: every call, repeats included. Not sent on SSE connect. |
bet_placed | THAI-HILO | A THAI-HILO room bet (POST …/rooms/{roomId}/bet) has been fully accepted: debited, stored, round still betting. One event per bet call. |
seed_slot_updated | THAI-HILO, LOTTO | A bet in a MULTI_CSEED round (POST …/rooms/{roomId}/bet, or LOTTO tickets/create) claimed a client-seed slot. Sent right after the debit and the claim, before the bet is finally stored: if the bet then fails (500, or 99942 with the stake refunded), the event has already gone out and the slot stays claimed. |
round_settled | THAI-HILO, LOTTO | End of settlement, ONLY when at least one player bet in the round (rounds with no bets settle silently). Sent before the round is marked settled. |
round_started
The server opened a new betting round for this room + currency + demo/real: at start-up, at the moment the previous round closes, in the periodic recovery sweep, as a replacement after a cancelled round, or when recovering a stuck round.
| Field | Type | Description |
|---|
roundID | string | New round id. Key is roundID (capital ID). |
endTime | string | When betting closes: RFC3339 with nanoseconds in the server's local time zone offset. Parse as a date. |
seedHash | string | SHA-256 hex server-seed commitment. |
{
"type": "round_started",
"data": {
"roundID": "tbq1x0a7c3e91f2",
"endTime": "2026-09-30T18:45:30.123456789+07:00",
"seedHash": "9f2c1e7a5b3d8f40c6a1e2d9b7f35c08e4a6d1b9c2f7e3a05d8b6c4e1f2a9d37"
}
}
- LOTTO never sends it. LOTTO clients learn about a new round only by calling Join Round or POST /gapi/v1/g/lotto/round (e.g. after
round_settled or once endTime passes).
- MULTI_CSEED rounds start with default seed slots, but the event does not carry
clientSeedSlots; call Join Round to get them.
player_joined
Any player's Join Round call (GET …/rooms/{roomId}/round) finds a betting or rolling round: every call, repeats included. Not sent on SSE connect.
| Field | Type | Description |
|---|
bettorCount | integer | Number of distinct players who have placed a bet in the current round (not viewers or joiners); 0 when nobody has bet yet. |
{
"type": "player_joined",
"data": {
"bettorCount": 3
}
}
- Carries no
roundID and no player identity.
- Because every Join Round call triggers it, a client polling Join Round floods the room with this event.
bet_placed
A THAI-HILO room bet (POST …/rooms/{roomId}/bet) has been fully accepted: debited, stored, round still betting. One event per bet call.
| Field | Type | Description |
|---|
username | string | Bettor's username for real players; "" for demo players. |
area | string | Areas of that call joined with "," (duplicates kept). |
amount | string | Total stake of that call, not per area. Decimal string, e.g. "150". |
bettorCount | integer | Distinct bettors in the round after this bet. |
{
"type": "bet_placed",
"data": {
"username": "player_demo01",
"area": "HI,1-2",
"amount": "150",
"bettorCount": 3
}
}
- LOTTO never sends it (bet privacy).
- No
roundID and no per-area amounts.
- The bettor also receives their own event, and real players' usernames reach every subscriber of the room.
seed_slot_updated
A bet in a MULTI_CSEED round (POST …/rooms/{roomId}/bet, or LOTTO tickets/create) claimed a client-seed slot. Sent right after the debit and the claim, before the bet is finally stored: if the bet then fails (500, or 99942 with the stake refunded), the event has already gone out and the slot stays claimed.
| Field | Type | Description |
|---|
index | integer | 0-based slot index. |
seed | string | Claimer's client seed. |
nonce | integer | Claimer's nonce at claim time. |
ownerPlayerID | integer | Claimer's player id. Key is ownerPlayerID (capital ID). |
{
"type": "seed_slot_updated",
"data": {
"index": 0,
"seed": "player-seed-01",
"nonce": 4,
"ownerPlayerID": 1000123
}
}
- Same keys as Join Round
clientSeedSlots items: replace the local slot at index.
- No
roundID: apply it to the round currently shown.
round_settled
End of settlement, ONLY when at least one player bet in the round (rounds with no bets settle silently). Sent before the round is marked settled.
| Field | Type | Description |
|---|
gameResult | object | THAI-HILO: {dice, winAreas} (no total / isHI / isLO; derive them or call …/result). LOTTO: {drawnNumber, bottom5, bottom4, bottom3, bottom2, front2, runBot, runFront} (no luckyNumber). |
playerResult | object | Present only when this connection's own player bet in the round; other players' results are never sent. |
roundID | string | Settled round. Key is roundID (capital ID). |
seedRevealed | string | Revealed seed: the 6-part MULTI seed string, or the plain round server seed (hex) for MULTI_CSEED. |
{
"type": "round_settled",
"data": {
"gameResult": {
"dice": [
1,
2,
6
],
"winAreas": [
"1",
"1-2",
"1-LO",
"2",
"2-LO",
"6",
"6-1",
"6-2",
"6-LO",
"LO"
]
},
"playerResult": {
"totalBet": "150",
"totalWin": "96",
"netWin": "-54",
"balanceAfter": "9946"
},
"roundID": "tbq1x0a7c3e91f2",
"seedRevealed": "default_THAI-HILO_1_1790768730123456789_126_4f1a9c2e"
}
}
- For THAI-HILO this is the reliable result source: GET …/result answers
status: "betting" for about 5 minutes after the round closes.
- A GET …/result call made right after this event may still say
rolling; retry briefly.
playerResult is filtered per connection to the caller's own player.
- Data keys arrive in alphabetical order.
Not sent
- No
round_cancelled, round_closed or round_rolling: nothing is sent when betting closes or a round is cancelled. Close the betting UI locally at endTime.
- No
round_started for LOTTO.
- No
player_joined or bet_placed for LOTTO.
- No
round_settled for rounds with zero bets.
- No heartbeat, ping or keep-alive frame of any kind.