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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
|
# Games and the play audit
## Implemented
| Game | Status | Notes |
| --- | --- | --- |
| Slots | Done | 5 reels x 3 rows, 5 paylines, 98.6% RTP |
| Roulette | Done | European wheel, split bets, 97.3% RTP |
| Blackjack | Done | Six-deck shoe per hand, S17, 3:2, hit/stand/double |
| Crash | Done | Local multiplier, server-owned clock and crash event, 99% RTP |
## Planned
| Game | Shape | Main open decisions |
| --- | --- | --- |
| — | — | Provable fairness for the crash point (`hash` commit/reveal) |
Crash rounds are private to one player and use the same request/response socket
plus one server-initiated line. A shared multiplayer round would need a lobby
and broadcast, which is a different product.
## Crash
- The multiplier is `exp(0.1386294 * t)`: 1.00x at the start, 2.00x at 5s,
10x at ~16.6s, capped at 1000x.
- The crash point is drawn when the round starts:
`max(1.0, (1 - 0.01) / (1 - u))` for a uniform `u`. That gives
`P(crash >= m) = 0.99 / m`, so a fixed-target cashout returns 99% of the
stake. The cap trims the RTP by a hair.
- The crash point is **not** sent to the client until the round ends. Knowing it
early would let a patched client cash out at `crash - epsilon` every round.
- The client draws the multiplier from its own monotonic clock, anchored when
`CRASH_START` arrives. That number is display only.
- The server computes the cashout multiplier from its own clock when the
request arrives. The client never sends a multiplier. A cashout that arrives
after the crash time loses the bet.
- Because `P(crash = 1.00x) = 1%`, an instant cashout returns the stake 99% of
the time and nothing 1% of the time. There is no risk-free window, which is
why there is no cashout lockout.
- Payouts are `floor(bet * multiplier)` in whole Kr, so anything under 1.10x
on a 10 Kr bet returns 10.
- When the crash time passes, the server pushes `CRASH_BUSTED` on the same
connection without being asked. The client's event loop polls for keys and
drains the socket every 25ms.
- Leaving with Q or disconnecting during a round forfeits the reserved bet and
writes a `result = forfeit` audit row.
Client controls:
```
C cash out at the server's current multiplier
SPACE start the next round
+ or = / - change the next round's bet
Q forfeit an active round (bet is lost), or leave when idle
```
## Blackjack
- Six-deck shoe, shuffled fresh for every hand and discarded afterwards.
- Dealer stands on all 17s (including soft 17).
- Blackjack pays 3:2, rounded down to whole Kr.
- Hit, stand, and double; no split, insurance, or surrender.
- The bet is reserved when the hand starts. Leaving an active hand with Q, or
disconnecting mid-hand, forfeits the reserved bet; it is not refunded. A
settled hand is unaffected.
- A natural or dealer blackjack settles immediately.
Client controls:
```
H hit
S stand
D double (on the first two cards)
SPACE deal the next hand
+ or = / - change the next hand's bet
Q forfeit an active hand (bet is lost), or leave when no hand is active
```
## Roulette
- European wheel: 0-36, one green zero.
- The felt is drawn in full and bet with a cursor. Over the numbers the cursor
walks a grid twice as fine as the felt: on a cell it is a straight, on the
line between two cells it is a split, and on a crossing it is a corner. Every
number and every pair of adjacent numbers is reachable, as on a real felt.
- The status line names the current bet and its odds, for example
`Bet: 16/17 · pays 18x`.
- Bet types and returns (per unit staked, stake included):
| Bet | Covers | Return |
| --- | --- | --- |
| Straight | one number | 36x |
| Split | two adjacent numbers | 18x |
| Corner | four numbers | 9x |
| Dozen | 1-12, 13-24, 25-36 | 3x |
| Column | 1,4,...,34 / 2,5,...,35 / 3,6,...,36 | 3x |
| Outside | 1-18, 19-36, EVEN, ODD, RED, BLACK | 2x |
- 0 loses every outside bet, both dozens and every column. It pays a straight,
and split/corner bets containing 0 are not offered.
- Every chip is settled independently and added together. RTP is 36/37 =
97.30% because every bet uses the same European odds.
- Bet amounts and balances are not colored in the TUI.
Client controls inside roulette:
```
arrows move the cursor over the felt, including between numbers
ENTER place the current chip on the cursor
C clear all bets
+ or = / - change the chip size
SPACE spin
Q leave the table
```
The result is shown as a CS:GO-style horizontal strip: the ball marker stays
fixed and the wheel numbers scroll beneath it, ending on the winning number.
## Audit schema
Every settled play is one row in `plays`:
```
plays (
session_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
game_type TEXT NOT NULL,
bet_amount INTEGER NOT NULL,
winnings INTEGER NOT NULL,
balance_after INTEGER NOT NULL,
details TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
```
`details` is game-specific JSON:
```json
// slots
{"reels":"CCCCLLCLODGSDGS","win_type":"win","win_lines":"1","win_cells":"0,3,6"}
// roulette
{"number":"17","color":"black","bets":"{\"chips\":[{\"target\":{\"bet\":\"outside\",\"kind\":\"red\"},\"amount\":10}]}"}
// blackjack
{"player":["10S","6H","5D"],"dealer":["9D","8C"],"player_total":21,"dealer_total":17,"bet":10,"result":"win","winnings":20}
// crash
{"bet":10,"crash_point":3.0,"started_at":12.44,"phase":"cashed","result":"cashed","multiplier":2.0,"payout":20}
```
The balance update and the `plays` insert happen in one transaction
(`database::apply_play`).
### Reserved bets and restarts
Blackjack and crash reserve the bet before the round can settle. The debit and
a `pending_plays` row are written in one transaction
(`database::reserve_play`); settling deletes that row and writes the `plays`
audit row in the same transaction.
```
pending_plays (
session_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
game_type TEXT NOT NULL,
bet_amount INTEGER NOT NULL,
details TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
```
If the server dies with a round in flight, the reservation survives the process.
The next startup calls `recover_orphaned_reservations` before the listener
accepts connections, and each leftover is settled as a **forfeit**: the bet is
not refunded, and the audit row gets `winnings = 0`,
`details.result = "abandoned"`, `details.reason = "server_restart"`.
The sweep runs once per process, never per connection. A per-connection sweep
would settle another player's live hand, and only one server process may own a
database.
## Wire protocol
Slots keeps its original exchange:
```
PLAY_START slots <bet>
GAME_RESULT <session> <winnings> <bet> <balance> <reels> <win_type> <win_lines> <win_cells>
```
Roulette sends the chips and gets a roulette-specific result:
```
PLAY_START roulette <total> <bets-json>
ROULETTE_RESULT <session> <winnings> <bet> <balance> <number> <color> <bets-json>
```
`bets-json` is `{"chips":[{"target":{...},"amount":N}]}`. A target is one of
`{"bet":"straight","number":17}`, `{"bet":"split","a":1,"b":2}`,
`{"bet":"corner","numbers":[1,2,4,5]}`, `{"bet":"dozen","index":1}`,
`{"bet":"column","index":0}` or
`{"bet":"outside","kind":"red"}` (`low`, `high`, `even`, `odd`, `red`,
`black`). The server rejects unknown, out-of-range or duplicate-number
targets, and requires the total to equal the sum of the chips.
The client can read back its settled plays:
```
HISTORY [count]
HISTORY <json-array>
```
`count` defaults to 20 and is capped at 50. Each record carries `game`, `bet`,
`winnings`, `balance_after`, `at` (UTC) and the game's own `details` object,
newest first. `HISTORY` without a session returns `ERROR Not logged in`.
Blackjack is a multi-step session:
```
PLAY_START blackjack <bet>
BLACKJACK_STATE <json>
BLACKJACK_ACTION <session> hit|stand|double|cancel
BLACKJACK_STATE <json>
```
`BLACKJACK_STATE` carries the player/dealer cards, totals, actions, balance,
result and winnings. The dealer's hole card is `?` until the hand is done.
Crash sends one line when the round starts, one line when it crashes (without a
request), and answers the cashout:
```
PLAY_START crash <bet>
CRASH_START <json>
CRASH_CASHOUT <session>
CRASH_STATE <json>
CRASH_BUSTED <json> # pushed when the crash time passes
CRASH_FORFEIT <session>
CRASH_STATE <json>
```
`CRASH_STATE` carries `session_id`, `phase` (`running`, `cashed`, `crashed`,
`forfeit`), `bet`, `balance`, the current/final `multiplier`, `potential`,
`crash_point` (null while running), `payout` and `result`.
|