DocsRecipes
Plain Node Server
A single-file Dice server on node:http with many sessions, player-chosen client seeds, settled bets, rotation and a history that gains seeds as they are revealed.
The server in Your first verified round has one session for the whole world and a client seed nobody chose. This one fixes both and settles the bet. It's still one file and node:http, with @galabet/fair as the only import that isn't part of Node.
| Route | Body | Answers with |
|---|---|---|
POST /session | none | A new sessionId, plus the commitment, client seed and next nonce |
GET /commitment | Commitment, client seed and next nonce for the session | |
POST /client-seed | { "clientSeed": "..." } | The seed that was retired, and the new commitment |
POST /bet | { "target": 50, "over": true, "stake": 25 } | The record without its seed, the bet, and how it settled |
POST /rotate | none | The seed that was retired, and the new commitment |
GET /history | Every bet of the session, with serverSeed added where it has been revealed |
Every route except the first reads the session id from an x-session header. The id is 16 random bytes, which makes it a bearer token: whoever has it is the player.
The File
import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import { assertClientSeed, commit, createClientSeed, createServerSeed, play, verifyCommitment, verifyRecord } from '@galabet/fair';
const HOUSE_EDGE = 0.01;
const sessions = new Map();
function fail(status, message) {
throw Object.assign(new Error(message), { status });
}
async function freshSeed() {
const serverSeed = await createServerSeed();
return { serverSeed, commitment: (await commit(serverSeed)).commitment };
}
async function openSession() {
const sessionId = randomBytes(16).toString('hex');
const session = { ...(await freshSeed()), clientSeed: await createClientSeed(), nonce: 0, revealed: new Map(), history: [] };
sessions.set(sessionId, session);
return { sessionId, session };
}
// Retire the current seed and install a new one. The await comes first, so the swap itself is synchronous.
async function rotate(session, clientSeed = session.clientSeed) {
const next = await freshSeed();
const retired = { serverSeed: session.serverSeed, commitment: session.commitment };
session.revealed.set(retired.commitment, retired.serverSeed);
Object.assign(session, next, { clientSeed, nonce: 0 });
return retired;
}
const visible = ({ commitment, clientSeed, nonce }) => ({ commitment, clientSeed, nonce });
async function readJson(req) {
let text = '';
for await (const chunk of req) {
text += chunk;
if (text.length > 1024) fail(413, 'body too large');
}
let body;
try { body = JSON.parse(text); } catch { fail(400, 'body must be JSON'); }
if (!body || typeof body !== 'object' || Array.isArray(body)) fail(400, 'body must be a JSON object');
return body;
}
function parseBet({ target, over, stake }) {
const hundredths = Math.round(target * 100);
if (typeof target !== 'number' || hundredths / 100 !== target || hundredths < 200 || hundredths > 9800) fail(400, 'target must be 2.00 to 98.00 with two decimals');
if (typeof over !== 'boolean') fail(400, 'over must be true or false');
if (!Number.isSafeInteger(stake) || stake < 1) fail(400, 'stake must be a whole number of credits');
return { target, over, stake };
}
function settle(roll, { target, over, stake }) {
const rolled = Math.round(roll * 100), line = Math.round(target * 100);
const win = over ? rolled > line : rolled < line;
const chance = (over ? 10000 - line : line) / 10001;
const multiplier = Math.floor(((1 - HOUSE_EDGE) / chance) * 10000) / 10000;
return { win, multiplier, returned: win ? Math.floor(stake * multiplier) : 0 };
}
const server = createServer(async (req, res) => {
const send = (status, body) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
try {
const route = `${req.method} ${req.url}`;
if (route === 'POST /session') {
const { sessionId, session } = await openSession();
return send(201, { sessionId, ...visible(session) });
}
const session = sessions.get(req.headers['x-session']);
if (!session) fail(401, 'unknown session');
if (route === 'GET /commitment') return send(200, visible(session));
if (route === 'POST /client-seed') {
const { clientSeed } = await readJson(req);
try { assertClientSeed(clientSeed); } catch (error) { fail(400, error.message); }
const retired = await rotate(session, clientSeed);
return send(200, { retired, ...visible(session) });
}
if (route === 'POST /bet') {
const bet = parseBet(await readJson(req));
// Seed, commitment, client seed and nonce are read together, with no await in between.
const { serverSeed, commitment, clientSeed } = session;
const nonce = session.nonce++;
const { result, cursor } = await play({ game: 'dice', serverSeed, clientSeed, nonce });
const entry = {
record: {
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
commitment, clientSeed, nonce, cursor, result, at: Date.now(),
},
bet,
settled: settle(result, bet),
};
session.history.push(entry);
return send(200, entry);
}
if (route === 'POST /rotate') {
const retired = await rotate(session);
return send(200, { retired, ...visible(session) });
}
if (route === 'GET /history') {
return send(200, session.history.map((entry) => {
const serverSeed = session.revealed.get(entry.record.commitment);
return serverSeed ? { ...entry, record: { ...entry.record, serverSeed } } : entry;
}));
}
fail(404, 'not found');
} catch (error) {
send(error.status ?? 500, { message: error.status ? error.message : 'internal error' });
}
});
// ---- Everything below is the player. Delete it and listen on a fixed port to keep the server up. ----
await new Promise((ready) => server.listen(0, ready));
const base = `http://localhost:${server.address().port}`;
async function call(method, path, sessionId, body) {
const headers = { 'content-type': 'application/json', ...(sessionId ? { 'x-session': sessionId } : {}) };
const response = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
return { status: response.status, body: await response.json() };
}
const alice = (await call('POST', '/session')).body;
const bob = (await call('POST', '/session')).body;
console.log('two sessions, two commitments:', alice.commitment !== bob.commitment);
console.log('both start at nonce:', alice.nonce, bob.nonce);
const refused = await call('POST', '/client-seed', alice.sessionId, { clientSeed: 'lucky:seven' });
console.log('bad client seed:', refused.status, refused.body.message);
const changed = (await call('POST', '/client-seed', alice.sessionId, { clientSeed: 'alice picks this' })).body;
console.log('client seed is now:', changed.clientSeed);
console.log('the unused seed was revealed and fits the first commitment:', await verifyCommitment(changed.retired.serverSeed, alice.commitment));
const tooHigh = await call('POST', '/bet', alice.sessionId, { target: 99, over: true, stake: 25 });
console.log('bad target:', tooHigh.status, tooHigh.body.message);
const bets = [];
for (let i = 0; i < 3; i++) bets.push((await call('POST', '/bet', alice.sessionId, { target: 50, over: true, stake: 25 })).body);
console.log('nonces:', bets.map((entry) => entry.record.nonce).join(' '));
console.log('a bet response carries the seed:', bets.some((entry) => 'serverSeed' in entry.record));
console.log('multiplier for over 50:', bets[0].settled.multiplier);
console.log('win flags agree with the rolls:', bets.every((entry) => entry.settled.win === entry.record.result > 50));
console.log('returned is 49 or 0:', bets.every((entry) => entry.settled.returned === (entry.settled.win ? 49 : 0)));
console.log("bob's nonce has not moved:", (await call('GET', '/commitment', bob.sessionId)).body.nonce);
const withSeed = (history) => history.filter((entry) => 'serverSeed' in entry.record);
const before = (await call('GET', '/history', alice.sessionId)).body;
console.log(`history before rotation: ${withSeed(before).length} of ${before.length} records have a seed`);
await call('POST', '/rotate', alice.sessionId);
const after = (await call('GET', '/history', alice.sessionId)).body;
console.log(`history after rotation: ${withSeed(after).length} of ${after.length} records have a seed`);
const checks = await Promise.all(after.map((entry) => verifyRecord(entry.record)));
console.log('all of them verify:', checks.every((check) => check.ok));
const next = (await call('POST', '/bet', alice.sessionId, { target: 25.5, over: false, stake: 10 })).body;
console.log('first bet on the new seed has nonce:', next.record.nonce);
const mixed = (await call('GET', '/history', alice.sessionId)).body;
console.log(`history now: ${withSeed(mixed).length} revealed, ${mixed.length - withSeed(mixed).length} waiting`);
// Bob sends 20 bets at once, with a rotation dropped into the middle of them.
const burst = Array.from({ length: 20 }, () => call('POST', '/bet', bob.sessionId, { target: 50, over: false, stake: 1 }));
burst.splice(10, 0, call('POST', '/rotate', bob.sessionId));
await Promise.all(burst);
await call('POST', '/rotate', bob.sessionId);
const crowd = (await call('GET', '/history', bob.sessionId)).body;
const pairs = new Set(crowd.map((entry) => `${entry.record.commitment} ${entry.record.nonce}`));
console.log(`bob's burst: ${crowd.length} bets, ${pairs.size} distinct pairs of commitment and nonce`);
console.log('every one verifies:', (await Promise.all(crowd.map((entry) => verifyRecord(entry.record)))).every((check) => check.ok));
const stranger = await call('GET', '/history', 'not-a-session');
console.log('made-up session id:', stranger.status, stranger.body.message);
server.close();
two sessions, two commitments: true
both start at nonce: 0 0
bad client seed: 400 client seed must not contain ":" (reserved as the HMAC message separator)
client seed is now: alice picks this
the unused seed was revealed and fits the first commitment: true
bad target: 400 target must be 2.00 to 98.00 with two decimals
nonces: 0 1 2
a bet response carries the seed: false
multiplier for over 50: 1.9801
win flags agree with the rolls: true
returned is 49 or 0: true
bob's nonce has not moved: 0
history before rotation: 0 of 3 records have a seed
history after rotation: 3 of 3 records have a seed
all of them verify: true
first bet on the new seed has nonce: 0
history now: 3 revealed, 1 waiting
bob's burst: 20 bets, 20 distinct pairs of commitment and nonce
every one verifies: true
made-up session id: 401 unknown session
Run it with node server.mjs. It starts on a free port, plays both sides and exits. Seeds, session ids and rolls are random, so the player half prints only what's the same on every run.
To keep it up and poke at it by hand, delete everything under the dashed comment and end the file with server.listen(3000). The commands below need that server running on localhost:3000, and the session id is whatever the first command printed for you.
curl -X POST http://localhost:3000/session
curl -X POST http://localhost:3000/bet \
-H 'x-session: PASTE-YOUR-SESSION-ID' \
-H 'content-type: application/json' \
-d '{"target":50,"over":true,"stake":25}'
Those single quotes are for a POSIX shell. In Windows cmd, put double quotes around each header and around the body, and escape the body's inner quotes as \".
What Leaves the Process
A session holds six things. Only serverSeed is secret, and it's secret only until the next rotation.
visible() decides what a response may contain, and it works by picking three fields, not by deleting one. The difference shows up months later, when someone adds a field to the session: a picker doesn't leak it, a delete copy.serverSeed does. The bet handler follows the same rule. It builds the record from named values, and serverSeed isn't among them.
retired is the one place a seed goes out, and by then it no longer decides anything.
Reading Seed and Nonce Together
Look at the three lines in /bet between the comment and the call to play. The seed, the commitment, the client seed and the nonce are all taken from the session before the handler awaits anything. Node runs that stretch without interruption, so no rotation and no second bet can land inside it.
Move the commitment read below await play(...) and Alice's three bets still pass, because nothing else is happening while she plays. It breaks when a rotation arrives during the await: the roll comes from the old seed, the record is stamped with the new commitment, and it can never verify. Bob's burst in the driver is there for that. Twenty bets and a rotation go out at once, and the output shows 20 distinct pairs of commitment and nonce, all of which verify. We made that one-line move in a copy of this file and ran it three times. The last line but one printed false every time, and on one run the pairs dropped to 17.
rotate is written the same way round. It does its awaiting first, to make the next seed, and then swaps everything in one synchronous step.
A New Client Seed Retires the Server Seed
/client-seed doesn't only store a string. It rotates, and the nonce goes back to 0. If it kept the server seed, a player could return to a client seed they'd used before and meet the same nonces again, which means rolls they have already seen. Client seeds has the longer argument.
In the driver Alice changes her seed before betting, so the seed she gets back in retired was never used for anything. Revealing it costs nothing. The output line about it fitting the first commitment is her checking that the server isn't handing back some other seed.
Validation is assertClientSeed from the library, so the server refuses exactly what play would refuse: an empty string, more than 64 UTF-16 code units, a colon, or anything that isn't a string. Its message goes back to the player with a 400. There are no error codes in 0.1.0, so the message is all you can pass on.
Seeds Are Attached When History Is Read
A stored record is never edited. session.revealed maps a commitment to its seed, and /history adds serverSeed to a copy of each record whose commitment is in that map. That's why the same three records come back with 0 seeds before the rotation and 3 after it, and why the fourth bet, made on the new seed, is still waiting.
Keying by commitment and not by "the previous seed" matters once a session has rotated more than once. Each record says which seed it belongs to.
Two things to know if you build on this. The record with serverSeed added has a different record hash from the one you stored, and a signature made at bet time won't verify against it. And history entries are pushed when play resolves, so under concurrent bets the array is in completion order. Sort by nonce within a commitment if order matters to you.
Settlement
settle is the rule set from the Dice page: over wins above the target, under wins below it, equality loses both ways, a 1% edge on the multiplier, whole credits returned. It compares in hundredths, as integers, so no bet is decided by a floating point comparison. Over 50 pays 1.9801, which is why a winning stake of 25 returns 49.
The bet is stored next to the record, outside it. A GFS record describes where the roll came from and has no field for a stake.
What Still Separates It from Production
Everything is in memory. Restart the process and every live seed is gone, which makes every record that was waiting for a reveal uncheckable for good. No amount of care elsewhere makes up for that. Storing seeds and reserving nonces covers what to persist and when.
session.nonce++ is safe here for one reason: one process, one event loop. Start a second copy of this server behind a load balancer and two bets can take the same nonce, even if both copies read the session from a shared database. The nonce has to be reserved atomically in the store itself. The same page shows the race, and the Redis recipe is one way to close it.
There's no money. stake is a number in a request and returned is a number in a response, and nothing is debited or credited. A real /bet has to take the stake, reserve the nonce and pay the return in a way that survives a retry and a crash between any two of those steps.
The session id is the only credential, it never expires, and the sessions map only grows. There's no rate limit, no TLS, and routes match req.url exactly, so /history?page=2 is a 404.
Last, nothing here records when a commitment was first shown to the player. The server can prove the seed matches the commitment. It can't prove the commitment was published before the bet, and that's the half of the scheme a verifier can't check.
