DocsGames

Limbo

How one float becomes a multiplier, where the house edge goes in, and why about one round in fifty lands on exactly 1.00.

Limbo is the first mapper with a parameter that changes the answer. Same seeds, same nonce, three different declared edges:

edges.mjs
import { play } from '@galabet/fair';

const seeds = {
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
  nonce: 42,
};

for (const houseEdge of [0, 0.01, 0.05]) {
  const { result } = await play({ game: 'limbo', params: { houseEdge }, ...seeds });
  console.log(houseEdge, result);
}
Output
0 1.78
0.01 1.76
0.05 1.69

So a Limbo result means nothing without the edge it was calculated under, and that's why houseEdge travels in the record's params. Leave it out and the library assumes 0.01.

Formula

raw    = 1e8 / (float × 1e8 + 1)
result = floor(raw × (1 − houseEdge) × 100) / 100, and never below 1.00

A small float gives a big multiplier. With our float of 0.5611712262034416, raw is about 1.78, the 1% edge takes it to 1.764, and the floor leaves 1.76.

The + 1 stops a float of zero from dividing by zero. It also sets the ceiling.

range.mjs
import { limbo } from '@galabet/fair/games';

console.log(limbo(0));            // the smallest float
console.log(limbo(0, 0));         // same, with no edge
console.log(limbo(0.5));
console.log(limbo(0.99));
console.log(limbo(1 - 2 ** -32)); // the largest float
Output
99000000
100000000
1.97
1
1

Ninety-nine million, once in 4,294,967,296 rounds. Don't build a jackpot animation for it.

House Edge and Results of 1.00

Look at the bottom of that list. Both of the last two floats give 1.00, and they're not rare: any float above roughly 0.98 produces a raw value under 1.0203, the edge pulls it under 1.01, the floor takes it to 1.00, and a player who needed anything above 1.00 has lost. Count them:

ones.mjs
import { play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
let ones = 0, atLeastTwo = 0;

for (let nonce = 0; nonce < 20000; nonce++) {
  const { result } = await play({ game: 'limbo', params: { houseEdge: 0.01 }, serverSeed, clientSeed: 'galabet', nonce });
  if (result === 1) ones++;
  if (result >= 2) atLeastTwo++;
}

console.log(`exactly 1.00: ${ones} of 20000`);
console.log(`2.00 or more: ${atLeastTwo} of 20000`);
Output
exactly 1.00: 412 of 20000
2.00 or more: 9930 of 20000

The second line is the useful one for anybody checking a site's claims. The chance of reaching a target is close to (1 − houseEdge) / target, so with a 1% edge a 2.00 target should come in a little under half the time, 49.5%. The sample above landed on 49.65%. That's the whole business model of the game, stated in one division.

Settling Bets

Settlement isn't part of the library. The demo's rule is that the bet wins when result >= target, equality included, and it pays floor(stake × target) in whole credits. Notice that this is the opposite convention from Dice, where equality loses. Whichever you choose, write it where players can read it.

Known Pitfalls

houseEdge Is Not Validated by play

Pass houseEdge: 2 and you get 1.00 forever, with no error, because a negative product is floored up to the minimum. inspectRecord is stricter and accepts 0 to 0.5. Validate the parameter yourself before it gets anywhere near a bet.

Floating-Point Order in Ports

Use doubles, multiply the float by 1e8 and add 1 before dividing, apply the edge, multiply by 100, floor, divide by 100. Rearranging those steps changes the last digit on some rounds. Python with plain floats matches the reference:

limbo.py
import hashlib
import hmac
import math

seed = "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"

for nonce in (42, 43, 44):
    digest = hmac.new(seed.encode(), f"galabet:{nonce}:0".encode(), hashlib.sha256).digest()
    f = int.from_bytes(digest[:4], "big") / 2**32
    raw = 1e8 / (f * 1e8 + 1)
    print(nonce, max(1.0, math.floor(raw * (1 - 0.01) * 100) / 100))
Output
42 1.76
43 2.13
44 1.38
same-rounds.mjs
import { play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
for (const nonce of [42, 43, 44]) {
  const { result } = await play({ game: 'limbo', params: { houseEdge: 0.01 }, serverSeed, clientSeed: 'galabet', nonce });
  console.log(nonce, result);
}
Output
42 1.76
43 2.13
44 1.38

Three rounds agreeing isn't proof. The vectors file has 336 Limbo rounds at edges of 0, 0.01 and 0.04, and that is.

Errors

ThrownWhen
Nothing, for a bad houseEdgeSee above. play doesn't check it
houseEdge: expected a number from 0 to 0.5.inspectRecord, edge out of range
params.rows: not supported for limbo.inspectRecord, a parameter that belongs to another game