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):
npm install phoenix
Connecting
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)
{
"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
}
| Field | Description |
|---|---|
player_id |
Your player id for this session (echo of the connect param). |
balance |
Current wallet balance for this player. |
phase |
Current round phase: betting, flying, crashed, or waiting. |
round_id |
Id of the active round (changes each betting cycle). |
multiplier |
Current multiplier in hundredths (100 = 1.00x). |
crash_point |
Crash multiplier in hundredths; null until phases crashed / waiting. |
phase_ends_at |
Unix ms when the current timed phase ends (for countdown UI). |
server_time |
Server Unix ms at snapshot send time (for client clock skew). |
betting_ms |
Configured duration of the betting phase in ms (UI hint only). |
crashed_ms |
Configured duration of the crashed phase in ms (UI hint only). |
waiting_ms |
Configured duration of the waiting phase in ms (UI hint only). |
tick_ms |
Interval between multiplier tick events during flight (UI hint only). |
history |
Recent finished rounds (newest first), each with round_id and crash_point. |
active_bets |
Number of open bets in the current round. |
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 |
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
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
{
"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".
{
"round_id": "round_…",
"multiplier": 127,
"server_time": 1710000000120
}
const x = payload.multiplier / 100
crash
{
"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
{
"player_id": "studio_player_001",
"slotId": 0,
"amount": 10.0,
"auto_cashout": 200,
"balance": 990.0,
"round_id": "round_…"
}
auto_cashout is hundredths (200 = 2.00x) or null.
cashout
{
"player_id": "studio_player_001",
"slotId": 0,
"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 per slot (slotId).
channel
.push("place_bet", {
amount: 10,
slotId: 0, // optional (defaults to 0)
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. Optional slotId selects which slot to cash out (defaults to 0).
channel
.push("cash_out", { slotId: 0 }) // optional (defaults to 0)
.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):
channel.push("get_state", {}).receive("ok", (state) => console.log(state))
Minimal client
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, slotId = 0) =>
channel.push("place_bet", { amount, slotId, auto_cashout: autoCashoutHundredths }),
cashOut: (slotId = 0) => channel.push("cash_out", { slotId })
}
}
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, 0) // slot 0, auto cash out at 3.00x
game.placeBet(10, null, 1) // slot 1, no auto cash out
game.cashOut(0) // cash out slot 0
Integration checklist
- [ ] Connect with Phoenix Channels to
/socket→game:lobby - [ ] Persist
player_id - [ ] On
betting— bet UI + countdown fromphase_ends_at - [ ] On
tick— update multiplier (/ 100) - [ ] On
crash— crash FX atcrash_point / 100 - [ ] Handle
place_bet/cash_outerrors viaerror.reason - [ ] Sync clocks with
server_time - [ ] Never trust local multiplier for money — only server
cashout/ reply