# Studio API

External contract for integrating a game client (Unity / WebGL / Web) with the
Aviator crash PoC over **Phoenix Channels** (WebSocket).

This document covers **only** the network protocol. The game server is deployed
separately in the cloud — you connect to its public WebSocket URL.

PoC notes: no real payments or accounts. Each player gets a virtual balance of
`1000` on first connect.

---

## Endpoints

| What | Value |
|------|--------|
| Socket path | `/socket` |
| Channel | `game:lobby` |
| URL example | `wss://<host>/socket` (or `ws://` in local/dev) |

Install the Phoenix JS client (any environment that can speak Phoenix Channels):

```bash
npm install phoenix
```

---

## Connecting

```js
import { Socket } from "phoenix"

const playerId = "studio_player_001" // stable player id (string)

const socket = new Socket("wss://<host>/socket", {
  params: { player_id: playerId }
})

socket.connect()

const channel = socket.channel("game:lobby", {})

channel
  .join()
  .receive("ok", (snapshot) => {
    console.log("joined", snapshot)
    // snapshot: phase, multiplier, balance, round_id, timings, ...
  })
  .receive("error", (err) => console.error("join failed", err))
```

### Socket params

| Param       | Type   | Description |
|-------------|--------|-------------|
| `player_id` | string | Player id. If omitted, the server generates one. Persist it across sessions. |

### Join snapshot (`ok`)

```json
{
  "player_id": "studio_player_001",
  "balance": 1000.0,
  "phase": "betting",
  "round_id": "round_a1b2c3d4e5f6",
  "multiplier": 100,
  "crash_point": null,
  "phase_ends_at": 1710000005000,
  "server_time": 1710000000000,
  "betting_ms": 5000,
  "crashed_ms": 3000,
  "waiting_ms": 2000,
  "tick_ms": 100,
  "history": [],
  "active_bets": 0
}
```

`phase_ends_at` and `server_time` are Unix time in **milliseconds**.  
Align timers with: `skew = Date.now() - server_time`.

Prefer `phase` + `phase_ends_at` over hardcoding durations. Snapshot fields
`*_ms` are hints for UI only.

---

## Multipliers = integer hundredths (×100)

All multiplier fields on the wire are **integers**:

| Wire value | Display |
|------------|---------|
| `100` | 1.00x |
| `251` | 2.51x |
| `1000` | 10.00x |

```js
const displayX = multiplier / 100
```

`crash_point` is present only in phases `crashed` and `waiting`
(hidden while the plane is flying).

---

## Round phases

```
betting → flying → crashed → waiting → betting → …
```

| Phase     | Typical duration | What the client should do | Allowed commands |
|-----------|------------------|---------------------------|------------------|
| `betting` | ~5 s | Show bet UI + countdown | `place_bet` |
| `flying`  | until crash | Animate multiplier / plane (~10 updates/s) | `cash_out` |
| `crashed` | ~3 s | Play crash at `crash_point` | — |
| `waiting` | ~2 s | Idle / prep next round | — |

During flight the server publishes ticks; approximate shape of growth
(for smoothing between ticks if you want):

```
multiplier_hundredths ≈ round(exp(0.06 * t_seconds) * 100)
```

Do **not** use client-side multiplier for payouts. Authority is the server
reply / `cashout` event.

---

## Server → client events

```js
channel.on("phase", (payload) => { /* phase change */ })
channel.on("tick", (payload) => { /* multiplier update */ })
channel.on("crash", (payload) => { /* plane crashed */ })
channel.on("bet_placed", (payload) => { /* someone bet (including you) */ })
channel.on("cashout", (payload) => { /* someone cashed out */ })
channel.on("welcome", (payload) => { /* optional housekeeping */ })
```

### `phase`

```json
{
  "phase": "flying",
  "round_id": "round_…",
  "multiplier": 100,
  "crash_point": null,
  "phase_ends_at": null,
  "server_time": 1710000000000,
  "history": [
    { "round_id": "round_prev", "crash_point": 234 }
  ]
}
```

In `crashed` / `waiting`, `crash_point` is set (hundredths).

### `tick`

~every **100 ms** while `phase === "flying"`.

```json
{
  "round_id": "round_…",
  "multiplier": 127,
  "server_time": 1710000000120
}
```

```js
const x = payload.multiplier / 100
```

### `crash`

```json
{
  "round_id": "round_…",
  "crash_point": 351,
  "phase": "crashed",
  "phase_ends_at": 1710000003000,
  "server_time": 1710000000000
}
```

After `crash`, any still-active bets are lost.

### `bet_placed`

```json
{
  "player_id": "studio_player_001",
  "amount": 10.0,
  "auto_cashout": 200,
  "balance": 990.0,
  "round_id": "round_…"
}
```

`auto_cashout` is hundredths (`200` = 2.00x) or `null`.

### `cashout`

```json
{
  "player_id": "studio_player_001",
  "amount": 10.0,
  "multiplier": 185,
  "payout": 18.5,
  "balance": 1008.5,
  "round_id": "round_…"
}
```

`payout = amount * multiplier / 100` (rounded to 2 decimals).

---

## Client → server commands

Each `channel.push` gets an `ok` / `error` reply. Related broadcasts may also
go to all clients on the channel.

### `place_bet`

Only in `betting`. One bet per player per round.

```js
channel
  .push("place_bet", {
    amount: 10,
    auto_cashout: 250   // optional hundredths (2.50x); float 2.5 also accepted
  })
  .receive("ok", (res) => console.log("bet ok", res))
  .receive("error", (err) => console.warn("bet error", err.reason))
```

| `err.reason` | Meaning |
|--------------|---------|
| `not_betting_phase` | Not in betting phase |
| `already_bet` | Already bet this round |
| `amount_too_small` | `< 1` |
| `amount_too_large` | `> 10000` |
| `invalid_auto_cashout` | `< 101` (1.01x) |
| `insufficient_balance` | Not enough balance |

### `cash_out`

Only in `flying`, with an active bet.

```js
channel
  .push("cash_out", {})
  .receive("ok", (res) => {
    // res.payout, res.multiplier (hundredths), res.balance
  })
  .receive("error", (err) => console.warn(err.reason))
```

| `err.reason` | Meaning |
|--------------|---------|
| `not_flying_phase` | Not flying |
| `no_bet` | No bet |
| `already_cashed_out` | Already cashed out |
| `bet_lost` | Round already lost |

### `get_state`

Fresh snapshot (e.g. after reconnect):

```js
channel.push("get_state", {}).receive("ok", (state) => console.log(state))
```

---

## Minimal client

```js
import { Socket } from "phoenix"

function connectAviator({ url, playerId, onTick, onPhase, onCrash }) {
  const socket = new Socket(url, { params: { player_id: playerId } })
  socket.connect()

  const channel = socket.channel("game:lobby", {})
  channel.join().receive("ok", (snap) => onPhase?.(snap))

  channel.on("tick", (p) => onTick?.(p.multiplier / 100, p))
  channel.on("phase", (p) => onPhase?.(p))
  channel.on("crash", (p) => onCrash?.(p.crash_point / 100, p))

  return {
    channel,
    socket,
    placeBet: (amount, autoCashoutHundredths) =>
      channel.push("place_bet", { amount, auto_cashout: autoCashoutHundredths }),
    cashOut: () => channel.push("cash_out", {})
  }
}

const game = connectAviator({
  url: "wss://<host>/socket",
  playerId: "artist_01",
  onTick: (x) => { /* update plane / multiplier label */ },
  onPhase: (p) => { /* switch scene by p.phase */ },
  onCrash: (x) => { /* crash animation at x */ }
})

game.placeBet(25, 300) // auto cash out at 3.00x
game.cashOut()
```

---

## Integration checklist

- [ ] Connect with Phoenix Channels to `/socket` → `game:lobby`
- [ ] Persist `player_id`
- [ ] On `betting` — bet UI + countdown from `phase_ends_at`
- [ ] On `tick` — update multiplier (`/ 100`)
- [ ] On `crash` — crash FX at `crash_point / 100`
- [ ] Handle `place_bet` / `cash_out` errors via `error.reason`
- [ ] Sync clocks with `server_time`
- [ ] Never trust local multiplier for money — only server `cashout` / reply
