# Casino Integration Pitfalls — and how Apogee handles them

This document lists the 30 most common mistakes made in real-money casino
wallet integrations and what the Apogee platform does to prevent them. All
items marked ✅ are enforced by `apogee-api` automatically. Items marked 🟡
require cooperation from the operator's wallet service.

## Money correctness

### 1. Non-idempotent debit / credit ✅
**Mistake.** Operator's `/debit` is called twice for the same `txId` (retry
after client timeout) and the player is charged twice.

**Apogee.** Every `/v1/wallet/debit` and `/v1/wallet/credit` carries a
server-generated `txId` (or client-supplied one). Apogee caches the first
response in-memory + Firestore `transactions/{txId}` and replays it byte-for-byte
on any retry for 24 h. The operator still must be idempotent on their side
(we retry on transient failures), but Apogee will never ask them to process
the same `txId` twice unless it's a genuine retry of the identical payload.

### 2. Lost debit, completed round ✅
**Mistake.** Network stalls between Apogee and operator after the debit is
committed. Apogee times out, marks bet as failed, but the operator already
took the money.

**Apogee.** Outbound wallet calls have an 8 s hard timeout. On timeout Apogee
immediately fires a signed `/wallet/rollback` with the same `txId`. The bet
is refused on the client side and the player sees an "insufficient balance
or operator unreachable — please retry" message. Rollback is retried with
exponential backoff up to 5 times over 2 min.

### 3. Float / decimal rounding errors ✅
**Mistake.** `1.1 + 2.2 = 3.3000000000000003`. Balance drifts by satoshis
over thousands of rounds. Or: operator returns `42994.70` as a float and
the game displays `4299470.00` because it's treating it as an already-
scaled integer.

**Apogee.** All amounts in wallet traffic MUST be integers in the currency's
minor unit (cents, kobo, etoshi, etc.). The API rejects non-integer `amount`
with `400 bad_amount`. The game converts at wallet boundaries only:
`walletFetchBalance()` divides by `10 ** decimals` on response, and
`walletDebit()` / `walletCredit()` multiply by `10 ** decimals` on
request. Internal UI math stays in major units so every existing
`.toFixed(2)` formats correctly.

| Currency | Decimals | `42,994.70 Br` major | `4,299,470` minor |
|---|---|---|---|
| ETB, EUR, USD, KES, NGN, BRL, INR, … | 2 | 42,994.70 | 4,299,470 |
| JPY, KRW, UGX, VND, CLP, RWF, XOF, XAF | 0 | 42,994 | 42,994 |
| BTC, ETH | 8 | 0.00042994 | 42,994 |

See §6.1 of `docs/INTEGRATION.md` for the full operator contract.

### 3b. `roundId` / `txId` wire type drift ✅ (fixed 2026-04-11)
**Mistake.** Game client represents `roundId` as an integer counter, wallet
API forwards it to the operator as a JSON number, the operator's wallet
(written in a strictly-typed language like Go or Rust) fails to unmarshal
`roundId` into its expected `string` field and rejects the debit with
`invalid_body`. The player sees a red error, mashes BET in frustration,
every retry fails identically — **300 rejected debits in 4 minutes on the
operator's `/debit` endpoint** from a single session before anyone noticed.

**Apogee.** `apps/api/server.js` now coerces `roundId` to a string when
forwarding to the operator across all three paths — `/debit`, `/credit`,
`/rollback`:

```js
roundId: body.roundId != null ? String(body.roundId) : null,
```

Operators can rely on `roundId` always arriving as a JSON **string** in
their unmarshal contracts. Integer IDs on the client side remain supported
— apogee-api is the compatibility layer.

**The same rule applies to every cross-process ID field** (`txId`,
`refTxId`, `playerId`, `sessionToken`, `gameId`, `roundId`). If your
operator wallet unmarshals any of these as a non-string, tell us and we'll
add a compat coercion. Default: string everywhere.

**Bonus fix**: a rejected debit now trips a 2-second client-side cooldown
keyed on `b.cooldownUntil` + `b.cooldownReason = "rejected"`. Preflight
check 12 catches the retry and returns `rejected_cooldown` with a distinct
friendly message. Frustration-mashing can no longer hammer the operator
at >1 request/second from a single session. See docs/BET-PREFLIGHT.md §12.

### 4. Currency mismatch ✅
**Mistake.** Session is in `EUR`, bet request has `USD`, operator's wallet
silently converts at a bad rate.

**Apogee.** Currency is locked at session creation and sent on every
subsequent call. The operator MUST reject any mismatched currency.

### 5. Negative balance from operator ✅
**Mistake.** Operator responds `200 OK` with `{balance: -47}`. A bug on
their side gives the player negative funds, which the game happily shows
as "-47.00 EUR".

**Apogee.** Any `balance < 0` in an operator response is treated as
`operator_bad_response` and surfaced to the player as a fatal wallet error.

## Concurrency

### 6. Double-tap double-bet ✅
**Mistake.** Player double-taps BET on mobile; two debits fly out before
the first response returns.

**Apogee.** Per-session async mutex. While a debit is in flight for
`sess_X`, any other debit/credit for `sess_X` waits or fails-fast with
`409 session_busy`. Client also debounces taps at 120 ms and blocks the
button while in `pending` state.

### 7. Parallel tabs ✅
**Mistake.** Player opens the same game in two tabs with the same session
token. Both show balance 1000, both bet 600, one wins.

**Apogee.** Session document has a `version` counter. Every debit/credit
uses a Firestore transaction that fails if `version` moved since read. SSE
balance updates propagate within ~50 ms to both tabs.

### 8. Stale balance from optimistic updates ✅
**Mistake.** Game UI shows balance locally, debits optimistically, server
rejects, UI never reverts.

**Apogee.** v21 game client uses server-authoritative bets: no optimistic
local math in remote mode. The bet enters `pending` state; the `active`
state is only set after `/wallet/debit` returns `200`.

## Auth & replay

### 9. HMAC replay ✅
**Mistake.** An attacker replays a captured `/debit` request.

**Apogee.** Canonical string includes `timestamp` + `nonce`. Timestamps
older or newer than ±60 s are rejected. Nonces are stored in an in-memory
cache for 5 min and reused ones return `401 nonce_replay`.

### 10. Session token in URL logs 🟡
**Mistake.** Launch URLs like `/play?sess=sess_abc123` end up in nginx
access logs, referer headers, and browser history. Anyone with that token
has full control of the player's session.

**Apogee.** Session tokens are opaque random 32-byte hex strings with 30-min
TTL. We extend the TTL on every activity so idle tokens expire quickly. The
game strips the token from `location.search` after boot (`history.replaceState`).
Operators should always ensure `sess=` does not appear in their access logs.

### 11. Clock skew between client and server ✅
**Mistake.** Operator's VM is 3 minutes behind NTP. Every HMAC fails with
"timestamp out of window".

**Apogee.** 60-second tolerance each side. Server logs skew > 30 s as a
warning so ops can catch drifting clocks before they break.

### 12. Raw-body vs re-stringified hash mismatch ✅
**Mistake.** Signer hashes `JSON.stringify(body)` with `{"a":1,"b":2}`;
verifier hashes a reserialized `{"b":2,"a":1}` and gets a different SHA256.

**Apogee.** Signature is computed over the raw HTTP body bytes, never
re-serialized. Our own verifier reads `rawBody` directly from the socket
before any JSON.parse.

## State machine

### 13. Cashout without matching bet ✅
**Mistake.** Rollback fires `/wallet/credit` with the stake amount because
the game thought the debit succeeded.

**Apogee.** Every `txId` moves through `pending → committed → (rolled_back|
settled)`. Credits reference the original debit's `txId` via `refTxId`.
`/wallet/rollback` is only called on `pending` or `committed` debits and
never on credits.

### 14. Session expires mid-round ✅
**Mistake.** Player bets at 29:50 of a 30-min session; by the time they
cash out at 30:30 the session is dead and the credit fails.

**Apogee.** Every `/wallet/debit` and `/wallet/credit` bumps the session's
`expiresAt` by 30 min. A round that has a pending debit cannot be
invalidated by TTL until the round settles.

### 15. Round crashed during debit ✅
**Mistake.** The crash moment arrives while the debit is still in flight.
The game needs to decide: does the bet count or not?

**Apogee.** If `state.phase !== "betting"` when the debit response arrives,
the bet is refunded with `reason: "bet-window-closed"` and the player is
notified. Bets only enter `active` during the betting window. The refund
is **awaited** (never fire-and-forget) and carries `refTxId` pointing at
the original debit; if the refund fails, `wallet.blocked = true` hard-stops
all further betting on the session.

### 15b. Cancel tap during pending debit ✅ (fixed 2026-04-11)
**Mistake.** The UI renders a CANCEL button during `b.state === "pending"`
while the debit is in flight, but `handleAction` silently swallows the tap
via the top-level `walletBusy` guard. The debit resolves, the bet is
promoted to active, the round runs, the stake is lost. The player is
convinced they cancelled; the operator's ledger shows a committed debit
with no `roundId` or `refTxId`.

**Apogee.** The `walletBusy` guard now checks whether the in-flight op is
a pending debit; if so, it sets `b.cancelRequested = true` on the bet
object and returns. The commit path at the tail of the `walletDebit`
`.then()` reads this flag: if set, it issues a refund credit with
`refTxId = <debit txId>` and `reason = "bet-cancelled-pending"`,
awaiting the credit before unblocking the wallet. If the credit fails,
`wallet.blocked = true`.

Root cause of the 2026-04-11 zplay incident (12,200 ETB refund). Full
postmortem in `ops/ZPLAY-REFUND-2026-04-11.md`. The three defects
contributing were: (1) UI rendering a CANCEL affordance on a swallowed
click, (2) fire-and-forget refund credits on the active-cancel path, and
(3) wallet calls that omitted `txId`, `roundId`, and `refTxId` entirely
so forensic reconciliation was blind.

## Game math integrity

### 16. Client-computed multiplier ✅
**Mistake.** Cashout fires with `mult: 2.5` from the client but the game's
true crash point was `2.48`. Server trusts the client and overpays.

**Apogee.** Cashout multiplier is recomputed server-side from
`serverSeed + nonce` and the time delta since phase start. A client claim
is floor-clamped to `min(claimedMult, crashPoint)`.

### 17. Instant crash (divisor = 33) drift ✅
**Mistake.** `INSTANT_DIVISOR` hardcoded, per-merchant RTP ignored.

**Apogee.** Divisor is derived from `effectiveRtp` per merchant per
currency via `/v1/merchants/:id/rtp?currency=X`. Game fetches at boot.
See `docs/PROVABLY-FAIR.md`.

### 18. Max-win uncapped ✅
**Mistake.** Player bets 100 at 10000x, wins 1,000,000, operator's wallet
can't settle.

**Apogee.** `MAX_WIN_PER_ROUND` hard cap per game (default 100,000 minor
units × stake). Server rejects cashout requests exceeding the cap. Game
enforces a hard `MAX_CRASH = 10000x`.

### 19. Max-stake uncapped ✅
**Mistake.** Bot places a single 9,999,999 bet.

**Apogee.** `MAX_STAKE_PER_ROUND` cap configurable per merchant (default
500,000 minor units). Enforced both client-side (input max) and server-side
(`/wallet/debit` rejects).

## Anti-abuse

### 20. Missing rate limit ✅
**Mistake.** A script places 1000 bets per second.

**Apogee.** Per-session token-bucket rate limiter: 10 requests/second burst,
5/sec sustained. Exceeding returns `429 rate_limited`.

### 21. Session fixation ✅
**Mistake.** Player shares their launch URL with a friend; friend plays
with their balance.

**Apogee.** On the first `/wallet/balance` call for a session, Apogee
records `{ipHash, uaHash}`. Subsequent calls from a materially different
fingerprint return `403 session_fingerprint_mismatch` and terminate the
session. `ipHash` tolerates same-subnet (NAT/mobile) changes.

### 22. Self-exclusion bypass 🟡
**Mistake.** Operator marks player as self-excluded but their existing
session keeps playing.

**Apogee.** `/v1/wallet/balance` is called on every round start AND on
boot. Operators can reject any call with `403 player_excluded` or similar
to terminate the game.

## Observability & reconciliation

### 23. No audit trail ✅
**Mistake.** "Did we actually charge that 500 bet at 14:03?" — nobody
knows without scraping logs.

**Apogee.** Firestore `transactions/{txId}` collection records every
debit, credit, rollback, and operator response with timestamps, request
IDs, and signature verification details. Retention 90 days.

### 24. No reconciliation job 🟡
**Mistake.** Apogee's shadow session balance and operator's ledger drift
by a few minor units over a week. Nobody notices until a player disputes.

**Apogee.** Nightly reconciliation cron compares Apogee's `transactions`
sum against the operator's `/wallet/balance` for each active session.
Divergences > 1 minor unit are flagged in the admin panel.

### 25. Monitoring silence ✅
**Mistake.** Operator's wallet starts 500-ing at 2am; nobody notices for
8 hours.

**Apogee.** Every `[operator]` line in Cloud Run logs is surfaced in the
admin panel's **Reports → Wallet health** tab. Error rate > 5% over 1
minute triggers a pager alert.

## Client

### 26. Balance hidden while loading ✅
**Mistake.** Game shows "Loading…" where the balance should be, players
panic-click and make noise.

**Apogee.** Game always shows the last known balance (seeded at load time
as `—`, replaced with the server value as soon as it arrives). If the
wallet is unreachable at boot, a full-screen red blocker appears — never
a half-broken game.

### 27. Inconsistent currency display ✅
**Mistake.** Topbar shows `1000.00 EUR`, bet button shows `10.00 cr`
because someone hardcoded a placeholder.

**Apogee.** All currency labels route through `BASE_CURRENCY.symbol`
which is driven by the session's `currency` field. No hardcoded strings.

### 28. Silent network errors ✅
**Mistake.** `fetch` rejects, game continues in a broken state, player
loses money they can never recover.

**Apogee.** Every outbound wallet call on the client logs to console
with `[apogee]` prefix. Wallet fatal path shows a modal with the error
message verbatim so support can reproduce.

## Legal / compliance

### 29. Jurisdiction leak 🟡
**Mistake.** Player from a blocked jurisdiction gets a launch URL because
GeoIP wasn't checked at session time.

**Apogee.** `POST /v1/sessions` accepts optional `country` and `ipAddress`
fields; we log them but do NOT enforce — enforcement is the operator's
responsibility. Documented in integration guide.

### 30. Player protection limits 🟡
**Mistake.** Operator has daily loss limits but doesn't check them on
round start; player blows through the limit in a 2-minute streak.

**Apogee.** The operator's `/wallet/debit` can reject with `403 limit_exceeded`
and Apogee surfaces the operator's message directly to the player.
Apogee does not hold per-player limits itself.

### 31. Wrong launch path for multi-bundle games ✅ (fixed 2026-04-12)
**Mistake.** Operator launches a game via the "obvious" path
(`https://apogeetech.net/play.html?gameId=contrail`) and the
player silently sees a DIFFERENT game. This happens because
`play.html` is Skyward's hardcoded bundle — it ignores `gameId`
and always boots Skyward. Contrail, Apogee Hot 5, and any future
game that lives in its own HTML file would never load via this
path.

This is a very easy hazard to hit because `play.html` looks like
a generic game launcher but isn't. The failure mode is
indistinguishable from "the launch URL is broken" — the game
just shows up wrong, with no error.

**Apogee.** Both `index.html` and `play.html` now have a
cross-game launch guard at the top of the document. If the
`gameId` query param doesn't match the page's native game(s),
the page silently `location.replace()`'s to
`api.apogeetech.net/v1/launch?...&redirect=1`, which 302s to
the correct HTML bundle. `apogee-api` is the single source of
truth for per-game launch paths via `GAMES[].launchPath`.

Valid entry points that all work:

| Launch URL | Effect |
|---|---|
| `apogeetech.net/?gameId=contrail&...` | ✅ forwards to `/contrail.html` |
| `apogeetech.net/play.html?gameId=contrail&...` | ✅ forwards to `/contrail.html` |
| `apogeetech.net/play.html?gameId=skyward&...` | ✅ native — loads Skyward |
| `apogeetech.net/play.html?gameId=thermal&...` | ✅ native — loads Thermal (shared engine) |
| `apogeetech.net/contrail.html?...` | ✅ direct |
| `apogeetech.net/apogeehot5.html?...` | ✅ direct |
| `apogeetech.net/?gameId=apogeehot5&...` | ✅ forwards to `/apogeehot5.html` |
| `apogeetech.net/play.html?gameId=apogeehot5&...` | ✅ forwards to `/apogeehot5.html` |

**Canonical recommendation for operators**: always use
`apogeetech.net/?gameId=<id>&merchant=...&player=...&sess=<token>`
and let the forwarder pick the correct bundle. Do not hardcode
per-game HTML paths in your integration — they are subject to
change. The authoritative launch-path table lives in
`apps/api/server.js::GAMES[].launchPath` and is exposed via
`GET /v1/games`.

Root cause: when Contrail shipped, the cross-game forwarder was
added to `index.html` (catches the `/?gameId=` case) but not to
`play.html` (catches the `/play.html?gameId=` case). A zplay
integration test exercised the second path and the player saw
Skyward load silently. Fixed in `apogee-web:v30`.

### 32. `POST /v1/sessions` launchUrl joiner drift ✅ (fixed 2026-04-12)
**Mistake.** When `apogee-api` constructs the `launchUrl` in the
`POST /v1/sessions` response, it hardcoded a leading `&` to append
the `merchant` / `sess` / `currency` params. That works for games
whose `launchPath` already contains a `?` (e.g.
`/play.html?gameId=skyward` → `?gameId=skyward&merchant=...`), but
silently produces a malformed URL for games whose `launchPath` is
just a bare path (e.g. `/contrail.html&merchant=...`).

The browser then treats `contrail.html&merchant=...` as part of
the pathname, nginx 404s on the unknown path, and operator
integrations get routed to the marketing page instead of the
requested game.

**Apogee.** The session-creation handler now uses the same
joiner-aware pattern as `GET /v1/launch`:

```js
const joiner = game.launchPath.includes("?") ? "&" : "?";
const launchUrl = `${GAME_HOST}${game.launchPath}${joiner}${params.toString()}`;
```

Affected games: **Contrail** (launchPath `/contrail.html`). Skyward,
Thermal, and Apogee Hot 5 had a `?` in their launchPath and were
not affected. No operator-side change required — the next
`POST /v1/sessions` call returns a correctly-formed URL.

First shipped in `apogee-api:ecfb837c-...`.

### 33. Hot 5 remote session starts with balance = 0 ✅ (fixed 2026-04-12)
**Mistake.** `apogeehot5.js::bootstrapSession()` used to set
`state.balanceMinor = 0` for real operator sessions and wait for
"the first spin response" to populate the balance. Players who
launched the game and hadn't yet spun saw a 0 balance, no data,
and a UI that looked broken.

**Apogee.** `bootstrapSession()` now calls
`GET https://api.apogeetech.net/v1/wallet/balance?session=<token>`
at boot time and populates `state.balanceMinor` before the player
sees the UI. If the call fails, the player gets a "Wallet
unreachable" red error banner (not a silent 0 balance).

First shipped in `apogee-web:<next-build-id>` (2026-04-12). Same
pattern as Skyward's `walletFetchBalance` call at
`game.js::boot()` line 3043, which has been in place since v1.

### 34. Skyward intro splash auto-dismiss race ✅ (fixed 2026-04-12)
**Mistake.** `play.html` shipped a "ENTER GAME" splash the player
had to click through before the game started. When we tried to
auto-dismiss it on non-localhost hosts via a `setTimeout(dismiss, 0)`
inside `wireIntro()`, the setTimeout task fired AFTER the render
loop showed the splash for ~16 ms — which some users perceived as
"the button didn't work" (the screen flashed and vanished).

**Apogee.** Instead of auto-dismissing the splash from JS, the
splash is now **stripped from the DOM** by an inline `<script>`
block in `play.html` that runs synchronously, before the
deferred `<script type="module" src="game.js">` even parses.
On localhost the splash is preserved as a click-to-boot affordance
for dev testing.

```html
<script>
  (function () {
    var h = location.hostname;
    if (h === "localhost" || h === "127.0.0.1" || h === "") return;
    var el = document.getElementById("intro");
    if (el && el.parentNode) el.parentNode.removeChild(el);
  })();
</script>
```

`wireIntro()` in `game.js` gracefully no-ops when `introEl` is
absent. Players launched via `/v1/sessions` now land straight in
the game — no click, no flash, no setTimeout race.

First shipped in `apogee-web:<next-build-id>` (2026-04-12).

---

## HTTP status codes from `api.apogeetech.net`

| Status | Meaning | Client action |
|---|---|---|
| 200 | Success | Commit the bet / update UI |
| 201 | Created | Session / merchant / key created |
| 400 | Malformed request | Don't retry, fix the body |
| 401 | Bad signature / expired timestamp / replayed nonce | Re-sign and retry once |
| 402 | Insufficient balance | Show insufficient, don't retry |
| 403 | Forbidden (session fingerprint mismatch, fraud flag) | Terminate session |
| 404 | Session / merchant / round not found | Prompt re-launch |
| 409 | Session busy (concurrent debit) | Retry after 100 ms |
| 423 | Game disabled | Show maintenance screen |
| 429 | Rate limit exceeded | Back off |
| 502 | Operator wallet rejected / unreachable | Show wallet fatal overlay |
| 500 | Apogee internal error | Show generic error, contact support |

## `transactions/{txId}` document shape

```json
{
  "txId": "tx_abc123...",
  "sessionToken": "sess_abc...",
  "merchantId": "mrc_edilbet",
  "playerId": "p_123",
  "gameId": "skyward",
  "roundId": "rnd_abc...",
  "type": "debit",
  "amount": 1000,
  "currency": "ETB",
  "reason": "bet",
  "state": "committed",
  "createdAt": "2026-04-11T14:03:00.000Z",
  "committedAt": "2026-04-11T14:03:00.412Z",
  "operatorResponse": { "balance": 44000, "currency": "ETB", "txId": "tx_abc123..." },
  "operatorLatencyMs": 412,
  "fingerprint": { "ipHash": "...", "uaHash": "..." },
  "requestId": "req_..."
}
```
