Bitcoin Arena is an open engine for games and prediction markets played for real bitcoin. It holds the stakes, checks every result and pays the winners. You bring the game. Tick Tock, Next Block is the first one, and yours can be next.
A game that pays real money usually needs a company: a server, a wallet to hold players' money, a way to stop cheaters and someone to pay out. Bitcoin Arena does all of that for every game on it, so a developer writes only the game.
Stakes are locked in a federation of independent nodes (Fedimint). No single node can take them, and neither can you.
Your game's rules run as a deterministic core. Independent nodes replay every run from the player's inputs. A run that does not replay to the same score is not paid.
Pick a payout rule in your manifest: the rank curve for games, pari-mutuel for markets. The nodes compute the table, and every node gets the same answer to the millisatoshi.
A game exists once its developer signs its manifest. Nobody has to approve it. Each node chooses which games it hosts and runs them for you.
One seed and one wallet work across every game. A player who has played Tick Tock, Next Block can stake on your game without a new sign-up.
Half of every player donation goes to the game's developer, on every node that hosts it, for as long as people play.
Every paid entry is split the same way. The rates are fixed in the protocol, the same for everyone. Here is a 10,000 sat stake with the default donation:
Taken from every paid stake and split three ways: the nodes that replay the runs, the nodes that sign the payouts, and the guardians that hold the money. Free rounds pay no fee.
The default is 5%, and the player can lower it to 0. It is always split half to the game's developer and half to the protocol developers.
In a game, the best runs win the stakes of the runs that failed. In a market, the right picks win the stakes of the wrong ones.
Players play a run, for example a 90-second drive. Your
core computes the score from the player's inputs. The nodes replay each run with the same core and sign
the score.
Payout: the rank curve. A run that delivers and reaches the minimum score gets its stake
back. The best quarter of those runs share the stakes of the runs that failed. Each rank down gets 3/4 of the
rank above.
Players pick one outcome, for example "the bitcoin
price closes above 100k". There is no core. The outcome is signed by attestors that you name in the
manifest: k of n of their keys, for example 2 of 3.
Payout: pari-mutuel. The stakes of the wrong
picks are shared among the right picks, in proportion to what each staked. There is no house and no
counterparty.
Your game and its rules are fixed by a manifest, a short signed record. Its hash is the game's id, and every round names the exact manifest it plays, so an update never changes a round already written.
Below, a whole game called coin-count: each tick the player presses a number, and the score is the sum. Real games are bigger, but they have exactly these parts.
Math.random, the network or files. Every core has the same small interface:
// core.js - coin-count
export const CORE = {
name: 'coin-count',
RUN_TICKS: 600, // the longest run a round may ask
runTicksFits: (t) => Number.isInteger(t) && t >= 1 && t <= 600,
INPUT_WORDS: 5, // a tick carries five input numbers
start: (seedWord, config, { runTicks }) => ({ seed: seedWord | 0, sum: 0, ticks: 0, length: runTicks }),
eventsOf: () => ({ count: { liquidations: 0, loads: 0, samples: 0, tapes: 0 }, before() {} }),
step(st, words) { if (st.ticks < st.length) { st.sum += words[0] | 0; st.ticks++; } },
stateHash: (st) => sha256(`coin-count:${st.seed}:${st.sum}:${st.ticks}:${st.length}`),
result: (st) => ({ outcome: st.ticks < st.length ? null : 'DELIVERED', score: st.sum }),
};
The core's id is the SHA-256 of its source file. A config (a set of rule numbers) is named by its hash too.games/<game name>/index.html beside the Bitcoin Arena page.
The page loads it only if its SHA-256 is listed in your manifest (clients[]). A client changed by
one byte is refused and does not run. Your client runs inside the page and gets the engine as
window.parent.ARENA_GAME: your game's rounds, the player's identity and join. Stakes,
locks, the wallet and the seed stay the engine's, so your client never touches a key.
const A = window.parent.ARENA_GAME;
A.rows(); // this game's open rounds: id, line, joinable, entry close
A.identity(); // the player's name and key fingerprint (the same in every game)
A.join(roundId); // join, through the engine's own join and stake
A.back(); // back to the list of gamesarena-donor makes the key that receives your half of the donations. It also collects
them from the federation and pays them to your own Lightning node.
node packages/arena-donor/bin/arena-donor.mjs init ./my-game-dev --label "coin-count dev"
# prints recipientPk: the key your game's donation set namesnode packages/arena-donor/bin/arena-donor.mjs game-key ./my-game-dev
# writes ./my-game-dev/game-dev.pkcs8 (readable only by you) and prints your public key// body.mjs - coin-count, version 1
import { RESULT_TYPE, PAYOUT_RULE, NO_PARENT } from './packages/arena-protocol/src/manifest.js';
import { donationSetId } from './packages/arena-protocol/src/donation.js';
const hex = (h) => Uint8Array.from(Buffer.from(h, 'hex'));
export const DONATION_SET = [{ recipientPk: hex('02…your recipientPk…'), label: 'coin-count dev', shareBps: 10000 }];
export const BODY = {
gameName: 'coin-count', version: 1, parentManifestId: NO_PARENT,
resultType: RESULT_TYPE.REPLAY, payoutRule: PAYOUT_RULE.RANK_CURVE, rankNumerator: 3, rankDenominator: 4,
minScore: 1, runTicksMax: 600, roundPeriodMs: 0n,
cores: [{ hash: hex('…sha256 of core.js…'), configs: [hex('…sha256 of your config…')] }],
clients: [hex('…sha256 of games/coin-count/index.html…')],
gameDonationSetId: donationSetId(DONATION_SET),
};node packages/arena-donor/bin/arena-donor.mjs sign ./my-game-dev body.mjs
# {"step":"signed","game":"coin-count","version":1,"gameId":"…","file":"./my-game-dev/coin-count-manifest.hex"}
That id is your game. Change one byte of the manifest and the id changes too.GAME: the signed manifest, your core with its configs,
and your donation set. A node checks all of it at start and refuses a game whose pieces do not match its
manifest.
// coin-count/game.js - what a node operator adds to ARENA_GAMES
export const GAME = { name: 'coin-count', title: 'Coin Count', manifest, gameId,
cores: new Map([[CORE_HASH, { name: 'coin-count', hash: CORE_HASH, url: CORE_URL, adapter: CORE,
configs: new Map([[CONFIG_HASH, {}]]) }]]),
donationSet: DONATION_SET };
Then ask node operators to host it. Each operator decides alone, and the game appears in the lobby of every page
whose nodes host it.packages/arena-node/tools/games-regtest.mjs starts a private test chain with two nodes that host two
games, keeps a free round of each open and serves the page, so you see the game picker, your client and a join.
No coins are real there.version + 1 and the old game id as
parentManifestId. Rounds already written keep the manifest they named.Real stakes need the page's list. A page lets players stake on a replayed game only if the game is on its list of known games, with its cores and configs. That list ships with the page, so a game you have just signed plays for free first. Markets work differently, as the next section shows.
A market is the same manifest with resultType: RESULT_TYPE.ATTESTED and
payoutRule: PAYOUT_RULE.PARI_MUTUEL. It has no core and no run. Instead it names:
['above 100k', 'between', 'below 90k'];The round settles once the threshold has signed one outcome and the objection window has passed. If two valid reports disagree, the round settles on neither. A market that is not on the page's list can still take stakes: the page shows its developer key and manifest id, and the player must confirm a second time. Bitcoin Arena's own test market btc-close has its own client, loaded by its hash, exactly as described in step 2.
A node is a small server program, arena-node. Together, the nodes are Bitcoin Arena: there is no
central server. Every paid round is run by a committee of nodes drawn by lot from all bonded nodes, and those
nodes share the round's 5% fee.
| Role | What the node does | Share of the 5% fee |
|---|---|---|
| Game / verify | Replays every run of the round with the game's core and signs the score. It is drawn only from nodes that host that game. | one third, split among the seats |
| Settlement | Computes the payout table and signs it, so the federation pays it. | one third, split among the seats |
| Guardian | Holds a key of the Fedimint federation that keeps the stakes (for example 5 of 7). | one third, split among the round's guardians |
An example: in a round with 7 seats in each role and 100,000 sats staked, the fee is 5,000 sats. Each pool
gets about 1,667 sats, so one seat earns about 238 sats per role, or about 714 sats for a node that sits in all
three. A node earns this in every round it serves, around the clock, and its dashboard
(/dashboard) shows what it earned per day, month, year and game.
fedimintd and a seat in the federation.# a node hosting Tick Tock, Next Block, on port 31221, with its store in node.db
ARENA_BITCOIND_URL=http://127.0.0.1:48332 \
ARENA_BITCOIND_COOKIE=$HOME/.bitcoin/testnet4/.cookie \
ARENA_BIND=0.0.0.0 \
ARENA_GAMES=packages/nextblock/plugin/nextblock.js \
node packages/arena-node/src/main.js 31221 node.db
ARENA_GAMES: the games you host, as a comma-separated list of modules. Add a game here, and remove one
here.ARENA_SERIES_FILE: the published round series your node holds. It is read again without a restart.ARENA_PEERS: other nodes' addresses, comma-separated.ARENA_STAKE_LOCK_BIN and ARENA_FEDERATION_CLIENT_DIR: the federation client your node
verifies stakes with (paid rounds).The node keeps its key in its store (node.db). Back the store up: it is your node's identity and what
its bond is tied to.
| Works today | Not built yet |
|---|---|
|
|
Get the code: the Bitcoin Arena source is not public yet. This page will link to it when it is.