1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
|
# 0004 — Protocol v5: frames, envelopes, time and commit–reveal
Status: accepted, 2026-11-08
## Context
The current wire is newline-delimited text over TLS. It has served the TUI and
GUI well, but four problems are now worth fixing before a browser gateway is
built on top:
- `LineBuffer` grows without bound if a peer never sends a newline, and there
is no explicit maximum message size.
- A reply is assumed to answer the request that is currently pending. A late
reply, a retry or a second in-flight request can clobber state.
- There is no idempotency: a gateway retry after a timeout can charge a bet
twice.
- The crash multiplier is anchored to each frontend's local clock, and the
crash point is only "trust the server".
The web gateway makes the first three worse (it retries and multiplexes) and
the browser makes the fourth visible (a page and the desktop can disagree).
## Decision
This is one wire change and one `MAJOR` release (`5.0.0`); there is no
compatibility mode.
### Framing
- Every packet is `[u32 length][payload]`, length big-endian.
- `payload` is UTF-8 text, at most 64 KiB. A larger declared length is a
protocol error and the connection closes; buffers never grow past one frame.
- The payload has no trailing newline.
### Envelope
- `REQ <id> [idem=<token>] <command>` — client request. `<id>` is a `u64`
chosen by the client, monotonically increasing on the connection.
- `RES <id> <body>` — the reply to that request.
- `EVT <seq> <body>` — a server-initiated event. `<seq>` is a `u64`,
incremented once per event on the connection.
The existing text grammar is the `<command>` and `<body>`; `HELLO 5.0.0`,
`PLAY_START crash 50`, `GAME_RESULT ...` keep their meaning.
`HELLO` is the first framed request. The server ignores every other command
before a successful handshake. The reply is
`HELLO <version> latest=<v> url=<url> time=<unix_ms>`.
### Correlation and idempotency
- The client keeps a request id until its `RES` arrives. Replies are routed by
id, never by "the next line".
- The server keeps a bounded 256-entry FIFO of `(id, payload, reply)` on each
connection. A repeated id with the same payload returns the stored reply
without executing the command again. A repeated id with a different payload
returns `ERROR Request id reused with a different command`.
- The cache is per connection and in memory. A reconnect is a new game session
by design; durable idempotency is deferred.
- `idem=<token>` is optional metadata for logs and for a future cross-
connection cache. The request id is the cache key.
### Clock and ticks
- `HELLO` and `PONG` carry the server's Unix time in milliseconds. `PING`
replies `PONG <unix_ms>`.
- Clients keep an offset between server time and their own clock. The offset is
for display and interpolation only; the server clock settles a cash-out.
- A crash start carries `started_at=<unix_ms>` and `commit=<hex>`.
- While a crash round runs, the server pushes at most one
`EVT <seq> crash.tick session=<id> t=<unix_ms> m=<multiplier>` per 100 ms.
The client interpolates between ticks and may keep rendering locally.
- `CRASH_BUSTED` remains an event, now with the reveal attached.
### Commit–reveal crash
- At round start the server draws 32 random bytes as the round seed and sends
`commit = sha256(seed)` in hex. It also sends the crash point's start time.
- The crash point is a pure function of the seed:
`u = u64_be(seed[0..8]) / 2^64`, then
`crash_point_from_uniform(u)`. The seed never reaches the client before the
round ends.
- When the round ends (`CRASH_STATE` after cash-out, or `CRASH_BUSTED`), the
server includes `seed=<hex>`. A client verifies `sha256(seed) == commit` and
recomputes the crash point. The UI can show "verified".
- This proves the crash point was fixed before the player acted. It does not
prove the seed was not ground to a favourable value; a hash chain with client
entropy is the documented next step if that matters.
## Consequences
- The TUI, GUI, server and gateway all move to v5 together; a v4 peer is
refused at `HELLO`.
- `LineBuffer` is replaced by a bounded `FrameBuffer`. Existing newline-based
tests and helpers are rewritten around frames.
- `ClientCore` allocates request ids and routes replies by id. Its `Effect`
carries the id, so an adapter writes the envelope and hands the `RES` back
with the same id.
- Crash rendering can be smoother and consistent across frontends, and the
crash result is independently checkable from the revealed seed.
- The per-connection cache is deliberately small and forgets on reconnect; a
retry after a reconnect is a new action, not a replay.
## Alternatives considered
- **JSON or binary frames**: cleaner types or smaller payloads, but a rewrite
of every parser for no scale need. Text inside a bounded frame keeps logs
readable and the change small.
- **Newline-delimited frames with a maximum line length**: still needs a
separate envelope and does not bound the buffer as cleanly; a declared length
is simpler.
- **Durable idempotency keyed by `(user, idem)`**: survives reconnects, but the
gateway owns retries on one connection and a reconnect is a fresh session.
Deferred.
- **Server seed plus client seed without a commit round-trip**: the server
sees the client seed before committing and can bias the pair; a half-measure
is worse than an honest per-round commitment.
- **Hash-chain epoch**: stronger anti-grinding, but requires persistent chain
state and a restart story. Deferred.
|