@buffalo-game/originals-protocol
v0.3.3
Published
Wire types, money handling and error codes shared by the Originals RGS and its game clients.
Readme
@buffalo-game/originals-protocol
The wire contract between the Originals RGS and every game client: request and response types, the error-code set, and the money primitives both sides share.
Licence. Proprietary. Use requires a current service agreement. See LICENSE. Publication on npm is for distribution to authorised licensees; it is not an offer of a licence to the public.
Most integrations do not depend on this package directly —
@buffalo-game/originals-client
brings it in and re-exports the types you need. Depend on it directly only if
you are writing your own client, or checking a server's behaviour against the
contract.
Install
npm install @buffalo-game/originals-protocol⚠️ Coming from 0.2.0? 0.3.0 is a breaking release in three independent
ways — amounts are JSON integers rather than decimal strings, multipliers are
bare floats rather than 1e6-scaled strings, and GameConfig.maxPayout is
removed. Only the multiplier change can be wrong without an error; the other
two are refused outright. CHANGELOG.md has the field list and
a migration order.
What is in it
| Module | What it holds |
|---|---|
| types | Every request and response the RGS accepts and returns |
| money | Amounts as bigint counts of millionths, and the JSON integer they travel as |
| currency | Symbol and display precision per currency code — metadata, never arithmetic |
| bet | How the bet ceiling is folded from the game's, the operator's, and the wallet's limits |
| settlement | The settlement contract in force: exact, versioned, recorded |
| validate | The validation primitives every request check is built from |
| request | Validation for each request the RGS accepts |
| errors | The ERR_* set |
| playback | Replaying a round's events from wherever the player already got to |
| jurisdiction | The twelve presentation rules a licence imposes, and the inert default |
| rational | Exact non-negative rationals, for figures that must not drift |
Money
Every amount is a non-negative bigint count of millionths of one currency
unit, and travels on the wire as a JSON integer of those same millionths —
1_000_000 is 1.00, which is Stake Engine's own encoding.
import { MONEY_SCALE, MAX_WIRE_MINOR } from '@buffalo-game/originals-protocol'
MONEY_SCALE // 1_000_000n — one unit of currency
MAX_WIRE_MINOR // 9_007_199_254_740_991n — the most the wire can carry⚠️ This changed in 0.3.0. Through 0.2.0 the same values travelled as
decimal-integer strings; a client written against that encoding sends '1000000'
where the server now requires 1000000, and is refused with ERR_VAL.
The scale did not move and the arithmetic rule did not soften. Nothing in this
system computes in number: an inbound amount is checked with
Number.isSafeInteger and converted to bigint in the same expression that
accepts it, so no amount is ever an operand of a floating-point operation. A
number here is a transport encoding, not a numeric type.
parseWireAmount answers { ok: true, value } or { ok: false, code, message }
— it never throws and never guesses:
parseWireAmount(1000000) // ✅ { ok: true, value: 1_000_000n }
parseWireAmount('1000000') // ❌ STRING_INPUT — that is the 0.2.0 encoding
parseWireAmount(1.5) // ❌ FRACTIONAL
parseWireAmount(-1000000) // ❌ SIGNED⚠️ The encoding is bounded, which the string form was not. MAX_WIRE_MINOR
is Number.MAX_SAFE_INTEGER minor units — about 9.007e9 currency units. Above
it JSON.parse has already changed the value before any check can run, so both
directions refuse rather than round: parseWireAmount fails inbound (ERR_VAL,
before a wallet is touched) and toWireAmount throws outbound (ERR_GEN),
never emitting a truncated figure. Bets never reach the wall; balances can —
a balance comes from the operator's wallet and nothing bounds it, so
high-denomination currencies hit it for real: roughly $360,000 in VND, $563,000
in IDR.
The decimal-integer string is not gone — it is no longer the wire.
parseDeclaredAmount still reads declaration sources (a version's
manifest.json bet ladder, the catalog, CLI arguments), because those bytes go
into logicHash and re-encoding them would invalidate every published RTP.
Reach for parseWireAmount for anything arriving over HTTP and
parseDeclaredAmount for anything read off disk; they are not interchangeable.
Multipliers are numbers too, but they are not scaled. payoutMultiplier
and maxWinMultiplier are bare JSON floats — 2 is 2×, and
payoutMultiplier: 1.9 sits next to payout: 19000000 in one response. The two
encodings differ on purpose and both are Stake's: a live /wallet/play answers
payoutMultiplier: 1.09 beside payout: 1090000, and the RGS schema behind
Stake's own SDK types the field PayoutMultiplier: number, described as
"Payout Multiplier for the bet. Payout / Amount."
⚠️ Through 0.2.0 these were 1e6-scaled decimal strings, so a client carried
forward from that release divides by 1e6 and lands a millionth of the real
figure. MULTIPLIER_SCALE is still exported — it describes the server's internal
Mult — but it takes no part in reading these two fields.
The scale did not move; only the wire did. Mult is still an exact 1e6-scaled
bigint, every multiplier is still computed as a Rational, and
toWireMultiplier does the single division at the boundary. payout remains
the authoritative number — the multiplier is literally Payout / Amount, and
RoundView pairs the two, both null while the round is still open. Never
re-derive a payment from a multiplier.
A round says whether it is open, not how it went
RoundView.active is a boolean: true while the round is still open, false
once it is settled.
⚠️ This field was status: 'ACTIVE' | 'ENDED' through 0.2.0, and
RoundStatus no longer exists. active is Stake Engine's spelling, and matching
it is what lets a stock Stake client cash out at all — ts-client calls
EndRound() only from inside if (data?.round?.active).
It is a lifecycle, not a result. A win, a loss, a cash-out and a push all report
active: false; what the round did is payoutMultiplier and the game's own
events. That is deliberate, and it is why the field is a boolean rather than a
union that could grow a third member.
RoundView.state is what the game draws, and its shape is the game's
Typed unknown, because it belongs to the game rather than to this package.
There are two shapes, and which one a game gets never varies round to round:
- a game computed in real time answers its
publicState()projection, an object — only ever what the player is entitled to see at that moment; - a game whose rounds are pre-generated answers an array of frames, the
round as its generator wrote it. It is the same array
GET /bet/replay/{game}/{version}/{mode}/{event}serves under the same name, so a shared replay link renders the round its player watched.
⚠️ state is not events. events is the server's stream for the round —
roundStart, the game's own events, winInfo, roundEnd — numbered from 0 by
the server, with the bare-float multipliers described above. A pre-generated
game's state keeps the generator's numbering and the generator's multiplier
encoding, which is typically ×100; a frame field named amount is in that
multiplier space and is not money, unlike RoundView.amount beside it.
Settle from payout. Animate from state.
AuthenticateResponse.round is absent when there is none — not null. It
was RoundView | null through 0.2.0; a live Stake /wallet/authenticate
carries exactly two top-level keys, config and balance, so ours now omits
the key too:
type Auth = import('@buffalo-game/originals-protocol').AuthenticateResponse
function isResuming(response: Auth): boolean {
return response.round !== undefined
}⚠️ if (response.round) is unchanged and still correct. What stops working is
response.round === null as a positive test for "no round in progress" — legal
TypeScript against an optional property, so it compiles and quietly never
matches. Write !response.round.
Twelve jurisdiction flags, and whose they are
GameConfig.jurisdiction is required and always sent: eleven booleans and
one duration, describing what the player's jurisdiction permits and requires of
the presentation.
config.jurisdiction.socialCasino // false
config.jurisdiction.disabledAutoplay // false
config.jurisdiction.displayRTP // false
config.jurisdiction.minimumRoundDuration // 0The names and types are Stake Engine's JurisdictionFlags, taken field for
field. Required rather than optional because a stock Stake client reads all
twelve off data.config.jurisdiction with no optional chaining — an absent
object is a TypeError on the first call a game makes, before a bet button is
ever drawn.
⚠️ These are compliance statements, and they belong to the operator.
socialCasino says the play is not for money; displayRTP and
displaySessionTimer are disclosures some regulators mandate;
minimumRoundDuration is a speed-of-play limit several set by law. So the
server resolves them from the operator's record and from nowhere else, because
the operator holds the licence and knows the player's jurisdiction — the RGS
does not, and filling them in on the operator's behalf would be the RGS making a
regulatory claim.
An operator that has declared nothing gets DEFAULT_JURISDICTION, every flag
false and minimumRoundDuration: 0. That is a placeholder meaning "the
operator has told us nothing", not a statement that no rules apply.
resolveJurisdiction folds a partial declaration onto it and is exported so an
integrator can build the same object the server does. Both tolerate undefined
and null for "not declared" — a cleared DynamoDB attribute and an absent JSON
object both arrive as the latter.
🔴 As shipped, nothing declares anything. The operator directory a deployed
RGS builds reads an id and a secret out of the legacy operator table and returns
those two fields alone; the table has no jurisdiction attribute and there is no
interface for setting one. So DEFAULT_JURISDICTION is not the fallback in
production — it is the whole of production, for every operator, until that
configuration path is built. OperatorRecord.betLimits is in exactly the same
state for exactly the same reason.
The wire contract is finished and the resolution logic is finished; the
configuration path is not. Nothing here changes shape when it lands — the same
twelve fields simply start carrying values somebody chose — so a client written
against this section today keeps working, and a client that only handles
all-false starts silently ignoring a real jurisdiction on that day.
import { DEFAULT_JURISDICTION, resolveJurisdiction } from '@buffalo-game/originals-protocol'
const declared = resolveJurisdiction({ minimumRoundDuration: 3 })
declared.minimumRoundDuration // 3 — the operator's value
declared.displayRTP // false — undeclared, so still inert
DEFAULT_JURISDICTION.socialCasino // false — the placeholder, not a clearanceThere is no maxPayout field, and there was one through 0.2.0. An
operator's per-round payout cap reaches the client as a bet limit: the server
divides it by maxWinMultiplier and the quotient is already folded into
effectiveMaxBet. Reading it was reading the input to a figure you already had.
Currency is display, and payouts are the same figure in every currency
⚠️ This changed in 0.3.0. A currency's decimals used to decide what an
amount meant: payouts were quantized to it, downward, per currency, so a ¥
player and a $ player were paid different figures for the same round. They are
not any more. Money is six decimal places deep for every currency alike, and
settleRational settles at that depth for everyone.
⚠️ And this changed again in 0.3.2. A payout that is not a whole number of
millionths is now floored there rather than refused — at most one millionth
of a currency unit per round, the same bound whatever the currency, which is why
the paragraph above still holds. Use settleRationalParts if you need the
residue; it comes back beside the payout.
That is Stake Engine's own model, in its own words: "Monetary values in the Stake Engine are integers with six decimal places of precision. Currency impacts only the display layer - it does not affect gameplay logic."
Three consequences for an integrator:
balance.decimalsis gone from the wire. ABalanceis{ amount, currency }, which is what Stake's/wallet/balanceanswers. Take the symbol and the number of places to render fromrequireCurrency(balance.currency).- A payout can be finer than the smallest coin. ¥0.0045 is a real payout and
is credited as
4500. Render it at the currency's precision if you like — but do not treat the rendered figure as the amount, and do not scale by it. formatMoneyrounds for display only. Pass{ decimals: MONEY_DECIMALS }when you need the exact figure.
import { formatMoney, requireCurrency, MONEY_DECIMALS } from '@buffalo-game/originals-protocol'
const jpy = requireCurrency('JPY')
formatMoney(4500n, jpy) // '¥0'
formatMoney(4500n, jpy, { decimals: MONEY_DECIMALS }) // '¥0.004500'The currency table now carries only codes Stake Engine also supports, with
Stake's own symbols and display precision. Codes that were ours alone were
removed in 0.3.0; a /launch naming one is refused with ERR_VAL and an
UNKNOWN_CURRENCY issue on player.currency.
Rounding, where it happens at all, is display rounding and is marked as such. It is never applied to money, and it is not left to the language.
Requests are a closed envelope, with room for the fields Stake sends
An unknown top-level field is refused, not ignored: a client that sends
metaa instead of meta would otherwise place a real bet under default
parameters and never hear about it. Nested game payloads — meta, action,
launch config — stay open; only the envelope is closed. Responses are the
other way round and stay additive, so a client may ignore fields it does not
know.
Two fields exist inside that envelope purely so that a body written to Stake Engine's shape is a legal request here:
const authenticate: import('@buffalo-game/originals-protocol').AuthenticateRequest = {
sessionID: 'the id the launch URL arrived with',
language: 'en', // optional; accepted, never read
}
const play: import('@buffalo-game/originals-protocol').PlayRequest = {
sessionID: authenticate.sessionID,
mode: 'BASE',
amount: 1000000,
currency: 'USD', // optional; checked, never used to select
}languageis accepted and deliberately unused. Every message this service produces is an English literal, so there is nothing for it to select, and it is not new information either — the operator declared it at/v1/launchand the launch URL handed it to the client as?lang=. It is taken because refusing it refused every standard Stake client on its first call.currencyis a cross-check, not a choice. The session's currency was fixed at launch and confirmed against the operator's wallet; sending a different one is refused withERR_VALrather than silently betting the session's. Send the currency you were given inbalance, or omit the field.
EndRoundRequest.roundId is optional for the same reason: Stake's
req_end_round is { sessionID } and nothing else. Name the round when you know
it — a named round is bound into the idempotency record before the wallet is
touched, so a retry provably settles the same round — and omit it to get Stake's
model, "end the round I am in". Only one round can be open per player and game,
so the resolved round is never ambiguous; what is lost is the ability to tell
"you already ended it" from "you never had one", since both answer
ERR_ROUND_STATE.
A third field moved rather than appeared: the game's own payload on /play is
now called meta, not params. Stake's req_play and req_action both carry
a meta, described as "values that are not determined by the RGS but by the Game
and the Game Provider… sent as is and is not validated by the RGS" — the same
slot, word for word. Renaming rather than accepting both is what avoids ever
having to answer "which one wins when a client sends both".
The type is unchanged and stays ours: Stake declares Meta as
Record<string, never>, an object with no legal properties, which no real game
parameter could be assigned to. Here it is Readonly<Record<string, unknown>>,
as params was.
A request still on the 0.2.0 name is refused, loudly, rather than played at
default parameters — by the compiler if you upgraded the types, and by the server
either way:
type Play = import('@buffalo-game/originals-protocol').PlayRequest
const fresh: Play = { sessionID: '…', mode: 'BASE', amount: 1000000, meta: { mines: 3 } }
const stale: Play = { sessionID: '…', mode: 'BASE', amount: 1000000, params: { mines: 3 } } // ❌ UNKNOWN_FIELDNothing else Stake's schema lists is accepted; metaa, paramz and every other
near miss is still an UNKNOWN_FIELD.
Errors
import { httpStatusFor } from '@buffalo-game/originals-protocol'Eleven codes. Eight match Stake Engine's set:
ERR_VAL · ERR_IPB · ERR_IS · ERR_ATE · ERR_GLE · ERR_LOC ·
ERR_GEN · ERR_MAINTENANCE
Three are ours, for round and idempotency state:
ERR_ROUND_STATE · ERR_IDEMPOTENCY · ERR_IN_PROGRESS
⚠️ ERR_IDEMPOTENCY and ERR_IN_PROGRESS are both 409 and call for opposite
responses — never resend, versus resend unchanged. See the client README.
