Flaparena
How a round runs, how the result is fixed before anyone can see it, how a pot is split, and what the $FLAPA contract can and cannot do. Written to be checked against the code rather than believed.
What this is
Eight birds race a gauntlet of gates. The crowd bets on who survives. Nobody plays the race.
A round lasts about ninety seconds and then another one starts. In each round eight birds fly a lane full of gates. Gravity pulls them down, they flap to climb, and flapping costs stamina. A bird that misses a gate, hits the floor or runs out of stamina is out. The last bird still flying wins.
You take part in one of two ways, and you can do both or neither.
- You bet. Back any bird in the field before betting closes. If that bird wins, you split the winning pool with everyone else who backed it, in proportion to what you staked. You do not need to own a bird to bet.
- You own a bird. A Bird is an NFT with a class and an ability rolled at mint. The operator enters it into a round and it flies without you. If your bird wins a round, you get the owner share of that round's pot, whether or not you placed a bet.
Every stake in a round goes into one pot. When the round settles, 90 percent of the pot goes back to the wallets that backed the winning bird, 5 percent goes to the winning bird's owner, and 5 percent buys $FLAPA on PancakeSwap V2 and sends it to the burn address. The 10 percent that does not return to bettors is the rake. Section 04 does the arithmetic in full.
The part people ask about first is whether the race is honest. It is decided on chain, in advance, by a simulation that runs entirely in integers and produces the same finishing order for everyone who runs it. The browser does not compute a result. It replays one. Section 03 sets out exactly how, including what the operator can still do and what it costs them.
Fixed facts
The race constants are defined in game/sim.js and mirrored line for line in FlapSimLib inside contracts/FlapArena.sol. The two timing values are the arena's starting settings and can be changed by the owner within the bounds in section 09. None of these are measurements and none change with activity.
What this paper does not contain
No contract addresses, because nothing is deployed. No pot sizes, holder counts, burn totals or win records, because no round has settled. No audit report, because no audit has been done. Anywhere a number would have to come from chain state, this paper leaves it out rather than filling it in.
What it does contain is every constant that is compiled into the contracts, every formula they run, and the file each one lives in. The source is in contracts/ in this repository. Where this paper and the code disagree, the code is right and the paper is a bug.
The round
A round moves through five states in one direction. Every transition is a transaction anyone can look up, and four of the five can be triggered by anyone.
Betting
The operator calls openRound(seedCommit, birdIds). Four things happen in that one transaction and all of them are public straight away.
- The field is set. Eight bird ids, checked to be distinct. Each one must be a real minted bird with a real owner, because the arena calls ownerOf on every id. There are no placeholder entries and no house birds.
- Stats are snapshotted. For each bird the arena reads its base stats, applies the owner's current Draft buffs, packs the six results into one storage word and emits them. Nothing read after this moment can change the field. A wallet that buys $FLAPA one block later races with the numbers it had at open.
- The seed commitment is published. A 32 byte hash goes on chain. The value it commits to stays secret until after betting has closed.
- The anchor block is fixed. The round records a specific future block number, 134 blocks ahead by default. Its hash becomes the second half of the race seed, and it does not exist yet.
Betting runs for a fixed window, 60 seconds by default, measured from the open. Odds are pari-mutuel, so they are not a price offered by a house. They are the current split of the pot, and they move every time somebody bets. The odds you see when you place a bet are not the odds you are paid at. Only the split at the close matters.
Closed
Once the betting window has passed and the anchor block has been mined, anyone holding the secret calls closeRound(roundId, secret). The contract checks the secret against the commitment, reads the anchor block hash, and combines the two into the final seed. Bets already reverted the moment the window closed, so the pot, the per-bird totals and the final odds were fixed before the anchor hash existed.
This is also where a round can be voided for lack of interest. If fewer than two wallets bet, or if the bets never reached two different birds, the contract voids the round instead of closing it. There is nothing to split when the whole pot sits on one bird.
Resolved
resolve(roundId) is callable by anyone. It unpacks the eight stat blocks, runs the 360 tick simulation on chain, packs the finishing order into a single 64 bit word and stores it. It also updates the two counters the Draft reads: every one of the eight owners has their rounds-entered count raised, and the winning owner's streak goes up by one while everyone else's resets to zero.
The order is now a fact on chain. The browser reads the seed and the field, runs the identical simulation locally, and plays the race back frame by frame.
Settled
settleAsset(roundId, asset) splits one pay token's pool by the formula in section 04. settle(roundId) does every pay token the round saw. Both are callable by anyone.
Money then moves by pull, never by push. A winning bettor calls claim(roundId, asset, slot). The winning bird's owner calls withdraw(asset) against a credited balance. The burn share is held in a per-asset escrow and flushed separately. Nothing is sent to a list of addresses at settlement, so one wallet that cannot receive a transfer cannot stall the round for anybody else.
Void
A round voids in one of two ways. Either the close found too few bettors or only one bird backed, or the secret never arrived and voidRound(roundId) was called after the reveal timeout. Both are open to any caller and both emit RoundVoided with a reason code. In either case every bettor claims their exact stake back and no rake is taken.
| State | Call | Who can call it | What it fixes |
|---|---|---|---|
| Betting | openRound | Operator | Field of eight, six stats each, the seed commitment, the anchor block |
| Closed | closeRound | Anyone with the secret | The final seed. The pot and the odds were already fixed at the window's end |
| Resolved | resolve | Anyone | The full finishing order, plus the rounds-entered and win-streak counters |
| Settled | settleAsset or settle | Anyone | Winners' pool, owner credit and burn escrow, per pay token |
| Void | closeRound or voidRound | Anyone | Every stake refundable at face value, no rake taken |
What happens inside the race
The simulation is a fixed loop of 360 ticks. A gate appears every 12 ticks, so there are 30 of them, and the gate plane sweeps the whole field eleven ticks after the gate appears. On every tick, in bird order 0 to 7, each bird pays its stamina drain, picks an aim point near the centre of the gate ahead, decides whether to flap, falls under gravity, moves its altitude, and covers ground.
- Flapping is a correction, not a fixed jump. A bird works out the launch velocity that would put it on its aim point in five ticks, then flaps with exactly that much, capped by its flap stat. A higher flap stat therefore buys more authority without costing accuracy.
- Stamina pays for it. A flap costs 4.00 stamina plus one fifth of the impulse used. Trimming a line is nearly free. Hauling across a big gate step is not.
- Gates get tighter. The half-gap starts at 130,000 units and loses 3,200 per gate, down to a hard floor of 42,000. Gate centres walk rather than teleport: each centre is a step of up to 230,000 units from the one before it, pulled a quarter of the way back toward mid-lane so the track cannot park itself against a margin for gates on end.
- Aim gets sloppier the lower your reaction is. The aim error span is 150,000 units at reaction 0 and shrinks by 120,000 across the reaction range. At reaction 0 the span is wider than the late-race half-gap, so a sloppy bird genuinely cannot thread the closing gates.
- Three ways out. Clip a gate pylon, touch the floor, or empty the tank. Hitting the ceiling is survivable and costs 40.00 stamina.
- A graze is a second chance with a bill. If a bird is outside the gap when the gate crosses, its dodge stat gives it a chance to survive as a graze instead of an elimination. A graze costs 140.00 stamina and pins the bird to the gap edge with zero velocity.
When the loop ends, survivors are ranked first by ground distance, then by remaining stamina, then by lane index. Eliminated birds are ranked after them, latest exit first. The bird at position zero is the winner.
The result
A commitment made before betting opens, mixed with a block hash that does not exist until after betting closes, fed into a simulation that returns the same answer everywhere.
The seed
The operator draws a random 32 byte secret before opening a round and publishes only its hash. The seed is not that secret. It is the secret combined with the hash of the anchor block and the round id.
// published by openRound, before any bet exists seedCommit = keccak256(abi.encodePacked(secret)) // the anchor block, fixed at open, 134 blocks ahead by default anchorBlock = block.number + anchorBlocks // computed by closeRound, after the betting window has ended anchorHash = blockhash(anchorBlock) // reverts if zero finalSeed = keccak256(abi.encodePacked(secret, anchorHash, roundId))
The round id is folded into the final seed, so the same secret used on two rounds still produces two different races. Note what the anchor block is and is not. It is a block number written into the round when the round opens, before anybody has bet. It is not chosen afterwards, and by the time the last bet lands it has not been produced yet.
The close has to happen while the anchor hash is still readable. That is not a policy choice. The EVM's blockhash opcode only reaches back 256 blocks, so past that point the mixing input is gone and the seed cannot be reconstructed by anyone, including the contract. The arena refuses to accept an anchorBlocks setting above 200, which leaves at least 56 blocks of margin, and it reverts with AnchorUnavailable rather than guessing if the hash has aged out. This is why the void path in section 02 exists.
The simulation
Given the seed and the eight stat blocks, the race is a pure function. It is written so that the Solidity and the JavaScript cannot drift apart.
- Integers only. No floats anywhere on the simulation path. Every division truncates toward zero, exactly like Solidity's integer division, and every quantity is bounded well under 253 so JavaScript doubles hold it exactly.
- One PRNG, four words wide. xorshift128 over four uint32 words. It uses only XOR and shifts, so there is no multiplication, no modulus and no carry to round differently between the two languages.
- The random stream is consumed in a fixed order. At each of the 30 gate boundaries the loop draws one word for the gate, then two words for each bird id 0 to 7 in ascending order, alive or dead. That is 30 × 17 = 510 draws in total, and the position in the stream never depends on what any bird does. The two implementations cannot desynchronise.
- The chain stores the answer, not the animation. The contract runs the loop and keeps only the finishing order. The frame buffer exists in the browser build for the replayer and nowhere else.
FlapSim.selfCheck() ships in game/sim.js and is the cross-language test. It asserts that the same seed always returns the same order, that every value stayed an integer, that the finishing order is a genuine permutation of the eight lanes, and that the measured draw count is exactly 510.
Why the browser cannot change the outcome
The client never reports a score. There is no message from the browser to the contract that says who won, how far a bird flew or how much stamina it had left. The only writes a player makes are placing a bet and claiming a payout, and neither carries race data. The finishing order was computed and stored on chain by resolve, before any browser drew a frame.
A modified client can therefore only lie to the person running it. If the local simulation disagrees with the stored order, the renderer treats the chain as correct and says so on screen rather than showing a race that did not happen.
What the operator can still do
Commit-reveal is not magic and this section does not claim it is. Here is the exact residual power, and what limits it.
- They cannot grind a winner in advance. The operator knows the field when they commit, so without mixing they could search secrets offline until they found one that makes a chosen bird win, then bet on it once betting opened. Mixing in the anchor block hash removes this. That hash does not exist at commit time, so no amount of offline searching fixes an outcome.
- They cannot move the anchor after seeing the bets. The anchor block number is written into the round by openRound and never rewritten. There is no function that changes it, so the operator cannot wait for the pot to fill and then pick a block whose hash they prefer.
- They can withhold the secret. Once the anchor block is mined the operator can compute the seed privately, run the race, see the winner, and decide not to call closeRound. This is real and the seed design alone cannot remove it. Note the shape of it. They cannot swap one winner for a different winner. They can only choose between this result and no result. The contract makes the second option worthless to them: after the reveal timeout, anyone at all can call voidRound, every stake becomes claimable at face value, and no rake is taken. A withheld round pays the operator nothing.
- The producer of the anchor block has one re-roll. Whoever builds the anchor block can look at the hash their block would have, and if they dislike it, drop the block so a different validator produces a different one. That costs them the block reward and gives them a fresh sample, not a chosen value. It is a coin flip they pay to re-flip, not a dial they can set.
- The two powers stack if one party holds both. An operator who also produces the anchor block can re-roll the hash at the cost of a block, then still choose only between closing and voiding. Nothing in this design prevents that. It is bounded by the same two costs, and both leave public traces: a dropped block, and a RoundVoided event with reason code 2.
The seed design makes the outcome unpredictable to everyone, including the operator, until after betting has closed. It does not make the operator unable to abandon a round they do not like. It makes abandoning one visible, refundable and unprofitable. There is no bond and no slashing in this contract, so the deterrent is the refund and the public event, not a financial penalty. If you want a stronger guarantee than that, the thing to check is the count of RoundVoided events with reason 2 once rounds are running, not a claim on this page.
The pot
Every stake joins the pot for the asset it was placed in. At settlement that pot splits three ways, and the three shares add up to exactly the pot.
The split
The rake is flat 10 percent. That is the part of the pot which does not come back to bettors: the owner share plus the burn share. It is taken once, at settlement, and nothing else is taken from a pot at any point. There is no house cut on top, no withdrawal fee and no charge for placing a bet.
The formula
All amounts are integers in the smallest unit of the pay token, which is wei for BNB and for $FLAPA. Every division truncates toward zero.
// inputs, read from the round at settlement, per pay token P = poolTotal, every stake placed in this asset this round S = winStake, the part of P placed on the winning bird si = stake placed on the winning bird by wallet i // the three shares. OWNER_BPS and BURN_BPS are both 500 ownerCut = (P * 500) / 10000 // 5% of the pot burnCut = (P * 500) / 10000 // 5% of the pot winnersPool = P - ownerCut - burnCut // the remainder, 90% or a wei more // what wallet i can claim, while stake is left after it payout(i) = (winnersPool * si) / S
winnersPool is written as a subtraction rather than a third percentage on purpose. It means the three shares reconstruct P exactly, with no rounding gap, for any pot size down to one wei.
The three shares then go to three different places. The winners' pool stays in the round and is drawn down by claim. The owner cut is credited to the winning bird owner's owed balance for that asset, which they take with withdraw whenever they like. The burn cut is added to a per-asset escrow and flushed later, which section 08 describes.
Worked example, illustrative
| Line | Amount, BNB | Share of pot | Note |
|---|---|---|---|
| Total pot P | 100.000000 | 100.0% | assumed input |
| Stake on the winning bird S | 25.000000 | 25.0% | three wallets: 12, 8 and 5 |
| Stake on the other seven birds | 75.000000 | 75.0% | loses in full |
| Winning pool | 90.000000 | 90.0% | P minus owner cut minus burn cut |
| Owner cut | 5.000000 | 5.0% | to the winning bird's owner |
| Burn cut | 5.000000 | 5.0% | buys $FLAPA, sends it to the burn address |
| Sum of the three shares | 100.000000 | 100.0% | closes exactly |
| Wallet | Stake | Share of winning stake | Payout | Net |
|---|---|---|---|---|
| A | 12.000000 | 48.0% | 43.200000 | +31.200000 |
| B | 8.000000 | 32.0% | 28.800000 | +20.800000 |
| C | 5.000000 | 20.0% | 18.000000 | +13.000000 |
| Total | 25.000000 | 100.0% | 90.000000 | +65.000000 |
The 65.000000 BNB that the winning side gains is the 75.000000 staked on losing birds minus the 10.000000 rake. Across all bettors in the round, 100.000000 went in and 90.000000 came out.
Where integer dust goes
Because payout(i) truncates, the sum of the truncated payouts can be a few wei short of winnersPool. The gap is called dust, and it is at most one wei per winning wallet. Rather than leave it stranded, the contract gives it to whoever claims last.
Settlement records two running numbers per asset: unclaimedPool, which starts at winnersPool, and unclaimedStake, which starts at S. Every claim subtracts from both. When a claimant's stake is the whole of the remaining stake, they are the last one, and they receive the whole of the remaining pool instead of a truncated share.
// inside claim(), for a winning stake if (stake >= unclaimedStake) { amount = unclaimedPool; // last claimant takes the remainder } else { amount = (winnersPool * stake) / winStake; if (amount > unclaimedPool) amount = unclaimedPool; } unclaimedPool -= amount; unclaimedStake -= stake;
| Step | Stake | Paid | Pool left | Working |
|---|---|---|---|---|
| Start | 7 | 0 | 90 | three wallets staked 3, 2 and 2 |
| First claim | 3 | 38 | 52 | 90 × 3 / 7 = 38.57, truncated |
| Second claim | 2 | 25 | 27 | 90 × 2 / 7 = 25.71, truncated |
| Third claim | 2 | 27 | 0 | last claimant, takes the remainder |
| Total paid | 7 | 90 | 0 | the pool closes exactly |
The pool therefore always empties completely, and no wei is ever left behind in a settled round. The cost is that the last claimant is a few wei better off than a perfectly proportional split, which at real pot sizes is far below the gas they paid to claim.
Edge cases
- Nobody backed the winning bird in this asset. There is no winning stake to split pro-rata, so that asset's pool is put into refund mode. Every bettor claims their exact stake back, and no owner cut and no burn are taken. The alternative would be taking the whole pot from bettors for a bird none of them chose.
- Fewer than two wallets bet, or the bets never reached two birds. The round voids at close. Nothing is split and every stake is claimable at face value.
- The secret never arrives. Anyone can void the round after the reveal timeout, with the same result.
- One asset refunds and another settles. Pools are independent, so this is a normal outcome rather than an error. A round can pay out in $FLAPA and refund in BNB in the same settlement.
Birds
A bird is two trait ids and six derived numbers. Class sets the baseline, ability moves one or two stats, and the Draft moves them a little further.
The six stats
Everything a bird does in the race comes from six integers. The simulation reads nothing else about it, which is why the contract stores nothing else.
| Stat | What it does | Min | Max | Scale |
|---|---|---|---|---|
| Flap | Ceiling on the upward velocity a single flap can buy | 11,000 | 28,000 | lane units per tick |
| Stamina | Size of the tank the bird starts with | 160,000 | 460,000 | 1 point = 100 units |
| Reaction | Aim accuracy. Higher means a narrower error span | 0 | 1,000 | index |
| Dodge | Chance of surviving a gate clip as a graze | 0 | 400 | per mille |
| Speed | Ground covered per tick, the first ranking tiebreak | 700 | 1,600 | distance units per tick |
| Drain | Stamina lost every tick before anything else happens | 120 | 240 | per tick, scale 100 |
The eight classes
A class is a base stat block and nothing more. The stamina pools were solved numerically rather than picked by hand: a damped iteration over thousands of races per round drove every class toward the same win rate, and the result was then checked against seeds the solver had never seen. The spread across the eight classes came down to roughly 1.17 times, from 8.8 times before balancing.
| Class | Flap | Stamina | Reaction | Dodge | Speed | Drain | PWR |
|---|---|---|---|---|---|---|---|
| Skylark | 18,500 | 317,000 | 760 | 90 | 1,180 | 200 | 185 |
| Kestrel | 21,000 | 264,500 | 700 | 150 | 1,320 | 202 | 200 |
| Ironwing | 16,200 | 379,500 | 700 | 60 | 960 | 180 | 178 |
| Stormcrow | 19,500 | 277,500 | 820 | 110 | 1,060 | 212 | 184 |
| Emberfinch | 24,000 | 242,000 | 660 | 130 | 1,240 | 208 | 196 |
| Nightjar | 16,500 | 329,500 | 880 | 200 | 1,000 | 195 | 212 |
| Galehawk | 22,500 | 259,000 | 720 | 100 | 1,400 | 216 | 191 |
| Cinderteal | 17,500 | 328,000 | 700 | 150 | 1,100 | 203 | 194 |
Read the table as a set of trades rather than a ranking. Ironwing carries the largest tank and the lowest drain but the weakest flap, so it survives the tight late gates and loses ground the whole way. Galehawk covers the most ground per tick and burns the most stamina doing it. Nightjar has the best aim and the best dodge on the smallest flap. The pools ended up inversely correlated with flap power, which is the right shape: the heavy hitters carry the smaller tanks.
The eight abilities
One ability is bound to a bird when it is minted and never changes. Each is a single integer modifier applied after the class base and before the Draft buffs.
| Ability | Effect on the class base | Reads as |
|---|---|---|
| Thermal Lock | Flap +6.0% | a bigger correction per flap |
| Second Wind | Stamina +10.0% | the largest single tank buff |
| Ghost Feather | Dodge +60 per mille | 6 more grazes survived in every hundred clips |
| Hair Trigger | Reaction +80 | a tighter aim error span |
| Draft Rider | Speed +8.0% | wins ranking ties on distance |
| Hollow Bone | Drain −12.0% | the tank lasts longer without being bigger |
| Gale Heart | Flap +3.0%, Speed +3.0%, Reaction +30 | three small pushes instead of one large |
| Iron Lung | Stamina +6.0%, Drain −6.0% | the endurance pairing |
How the numbers are derived
Stats are computed in one fixed order and clamped at the end. In the browser this is FlapSim.deriveStats(classId, abilityId, draft). On chain it is split across two contracts that run the same arithmetic: FlapBirds.baseStats does steps 1 and 2, and FlapSimLib.applyDraft does steps 3 and 4 when a round opens.
// 1. class base, all six read straight from the class table flap = CLASS.flap ; stamina = CLASS.stamina ; reaction = CLASS.reaction dodge = CLASS.dodge ; speed = CLASS.speed ; drain = CLASS.drain // 2. ability layer, pct is in tenths of a percent flap = flap + (flap * ABILITY.flapPct) / 1000 stamina = stamina + (stamina * ABILITY.stamPct) / 1000 reaction= reaction+ ABILITY.reactAdd dodge = dodge + ABILITY.dodgeAdd speed = speed + (speed * ABILITY.speedPct) / 1000 drain = drain + (drain * ABILITY.drainPct) / 1000 // 3. draft layer, see section 06 // 4. clamp every stat into its hard range
The advertised power rating is published here so nobody has to trust the number on a card. Each term truncates on its own before the sum.
PWR = (flap / 1000) * 3 + (stamina / 10000) * 2 + (reaction / 20) + (dodge / 4) + (speed / 40) - (drain / 10)
The roll
Minting a bird uses the same commit-and-reveal shape as a round, for the same reason. You call rollCommit(hash) with the roll price attached, where the hash covers your own address and a secret only you know. Two blocks later you call rollReveal(secret), and the traits fall out of a hash the contract builds then.
commitHash = keccak256(abi.encodePacked(msg.sender, secret)) anchor = commitBlock + 1 entropy = keccak256(abi.encodePacked(secret, blockhash(anchor), msg.sender, tokenId)) classId = entropy % 8 abilityId = (entropy >> 8) % 8 cosmetic = entropy >> 32 // 160 bits, art only
Your address is inside the commitment hash, so a pending commitment cannot be front-run or stolen by somebody who watched it go by. The reveal window is 250 blocks after the anchor. If you miss it, the anchor hash has aged out of reach and the traits cannot be derived, so expireRoll refunds your payment in full and clears the commitment.
There are no rarity tiers
This is worth stating plainly because the pattern is common elsewhere. The bird contract draws the class uniformly from eight and the ability uniformly from eight, and that is the whole roll. There is no tier, no weighted drop table and no pool that opens up at a higher price. Every one of the 64 combinations is equally likely on every roll.
The third value in the roll is a 160 bit cosmetic number. It changes what the bird looks like and nothing else. It is not read by the simulation, it is not part of the six stats, and no combination of it is worth more in a race than any other.
On-chain art
There is no image server and no IPFS pin. The bird contract stores the class id, the ability id and the cosmetic value, and tokenURI asks a separate art contract to render an SVG from those numbers, base64 encodes it into a data URI, and wraps it in base64 encoded JSON along with the six stats as attributes. Everything a marketplace shows comes back from one contract call.
The image is drawn from the traits every time it is asked for, so it cannot go missing, cannot be swapped after the fact, and does not depend on anyone continuing to pay a hosting bill. The art contract address can be replaced by the owner until it is sealed, after which setArt reverts and the art is fixed for good.
The web client draws the same bird from the same numbers, in game/sprites.js, as procedural canvas routines rather than image files. Each class has its own drawing routine because a single parameterised bird recoloured eight ways failed the only test that matters, which is telling them apart at 24 pixels while they move. Silhouette carries identity, colour confirms it.
The Draft
Six buffs. Each one reads exactly one fact that already exists on BNB Smart Chain, and every one of them is capped.
These are on-chain facts only. There is no reputation score, no social graph, no Discord activity, no off-chain snapshot and no oracle. Every input is a single view call to a FLAPARENA contract or to the $FLAPA token contract, made by the arena at round open. If a buff cannot be read from a public contract, it is not in this list.
All six read the owner wallet, not the individual bird. Two birds in the same wallet carry the same buffs. Selling a bird moves it to the buyer's buffs on the next round it enters.
The six
| Buff | Stat it moves | On-chain fact it reads | Read from | Full value | Cap |
|---|---|---|---|---|---|
| Tailwind | Speed | $FLAPA held by the owner wallet | Token balanceOf | 1,000,000 FLAPA | +12.0% |
| Ballast | Max stamina | Blocks since the wallet's last outgoing $FLAPA transfer | Token holdStreakBlocks | 5,760,000 blocks | +15.0% |
| Slipstream | Stamina drain | Rounds the wallet's birds have been entered into | Arena roundsEntered | 200 rounds | −20.0% |
| Quickbeak | Reaction | Lifetime bets the wallet has placed | Arena betCount | 500 bets | +20.0% of headroom |
| Updraft | Dodge | Consecutive round wins, reset to zero on any loss | Arena winStreak | 10 wins | +25.0% |
| Plumage | All five positives | Birds the wallet currently holds | Aviary balanceOf | 25 birds | +4.0% |
The rounds-entered and win-streak counters are written by resolve and by nothing else. A wallet cannot increment them by calling anything itself.
From a fact to a magnitude
Each raw fact is converted to a magnitude between 0 and 1000. The conversion is a straight ratio against that buff's full value, then a hard stop.
// value = the raw on-chain fact for this buff // full = that buff's full value, e.g. 1,000,000 FLAPA for Tailwind if (full == 0 || value == 0) m = 0; else if (value >= full) m = 1000; else m = (value * 1000) / full;
The curve is linear up to the full value and flat after it. Holding twice the full amount is worth exactly the same as holding the full amount. The thresholds can be changed by the owner through setThresholds, and the contract rejects a zero for any of the six, so a buff can never be made free.
From a magnitude to a stat
The magnitudes are then spent. This is the exact draft layer, run by FlapSimLib.applyDraft on chain and by FlapSim.deriveStats in the browser, applied after the ability layer and before the clamps.
allRound = plumage * 40 // up to 40000, which is +4.0% flap = flap + flap * (allRound) / 1000000 stamina = stamina + stamina * (ballast * 150 + allRound) / 1000000 speed = speed + speed * (tailwind * 120 + allRound) / 1000000 dodge = dodge + dodge * (updraft * 250 + allRound) / 1000000 drain = drain - drain * (slipstream * 200) / 1000000 // reaction eats the remaining headroom, never the current value, // so a high-reaction class still gains and 1000 is never breached reaction = reaction + (1000 - reaction) * (quickbeak * 200 + allRound) / 1000000
Plumage is the only buff that touches more than one stat, and it is the smallest by a wide margin. It contributes 4 percent at most, spread across flap, stamina, speed, dodge and reaction headroom. The other five each move one thing.
The whole layer, at its ceiling
This is a Skylark with the Thermal Lock ability, shown first with no buffs and then with all six buffs at magnitude 1000. It is the largest the Draft can ever be.
| Stat | No buffs | Every buff maxed | Change |
|---|---|---|---|
| Flap | 19,610 | 20,394 | +4.0% |
| Stamina | 317,000 | 377,230 | +19.0% |
| Reaction | 760 | 817 | +57 |
| Dodge | 90 | 116 | +28.9% |
| Speed | 1,180 | 1,368 | +15.9% |
| Drain | 200 | 160 | −20.0% |
What this means for a large wallet
Some of the Draft is farmable and that is the point. Holding more $FLAPA, holding it longer, entering more rounds and betting more often all raise your buffs, and all four are things the protocol wants people to do. What stops it deciding races is the ceiling. A wallet holding a thousand times the Tailwind threshold gets exactly the same Tailwind as a wallet holding the threshold, and the whole layer at its maximum is the table above: a fifth off the drain, a fifth of the reaction headroom, and single digit percentages elsewhere.
Every input is read at round open and packed into the round, so no fact can be changed after the field is visible. A wallet cannot claim a hold streak it does not have, because the streak is the block number of its own last outgoing transfer, written by the token contract on every send.
Being able to see the numbers matters more than any of this. FlapDraft exposes draftInputs and previewDraft for any address, so before a round opens anyone can read the six raw facts, the six magnitudes and the resulting stat block for any wallet in the field.
Tokenomics
One fixed supply minted at deployment, a transfer tax with a cap that cannot be raised, and a burn funded by betting.
Supply
The whole supply is minted to the deployer in the constructor and there is no function anywhere in the contract that creates tokens afterwards. burn(amount) is public, destroys the caller's own tokens, and reduces totalSupply.
The tax and its cap
$FLAPA charges a tax on trades against a flagged AMM pair. Wallet to wallet transfers are not taxed. Neither is any address on the tax exemption list, which is how the arena contract can buy and burn without paying a fee to itself.
| Parameter | Value at deploy | Hard cap | Changeable by |
|---|---|---|---|
| Buy tax | 250 bps, 2.5% | 500 bps, 5% | setTax, owner only |
| Sell tax | 250 bps, 2.5% | 500 bps, 5% | setTax, owner only |
| Buy plus sell combined | 500 bps, 5% | 500 bps, 5% | nothing can raise it |
The two defaults already add up to the combined cap. From the moment the contract is deployed, the only moves available to the owner are lowering a side or shifting weight between the two. setTax reverts with TaxTooHigh on any call that would break either the per-side cap or the combined cap, and there is no path to change those constants.
Where the tax goes
Tax accrues as tokens held by the token contract itself. When somebody sells and the held balance has reached the swap threshold, the contract swaps a batch of it for BNB through the PancakeSwap V2 router and forwards the BNB to the treasury address. The swap is wrapped so that a failure cannot block the transfer that triggered it: if the router reverts, the approval is cleared and the trade goes through untouched.
The batch cap exists so a large accumulated balance is sold across several trades rather than dumped in one. The contract keeps running counters for tax collected, tokens swapped, BNB routed and tokens burned, all readable by anyone.
Two separate flows
People often collapse these into one. They are not the same and neither funds the other.
| Flow | Funded by | Path | Effect on supply |
|---|---|---|---|
| Token tax | Buys and sells against the V2 pair | Held on the token contract, swapped to BNB, sent to the treasury | None |
| Rake burn share | 5% of every settled pot | Buys $FLAPA on PancakeSwap V2 and delivers it to the dead address | Circulating supply falls, total supply unchanged |
That last cell is a real distinction and this paper is not going to blur it. The arena does not call the token's burn function. It routes the swap so the $FLAPA lands directly at the address ending dEaD, which has no known private key. The tokens can never move again, but totalSupply still counts them, and the token's own totalBurned counter does not rise, because that counter only tracks explicit burn calls.
To count what the arena has taken out, read the $FLAPA balance of the dead address, or add up the BurnFlushed events the arena emits. Both are public. Anyone quoting a burn total should say which of the two numbers they mean.
When the pot being settled is already denominated in $FLAPA there is no swap at all. The escrowed amount is transferred straight to the dead address, which saves the gas and removes the price impact. Section 08 covers how the pools stay apart.
Launch limits and why they exist
The constructor derives a maximum transaction size and a maximum wallet balance from total supply rather than hardcoding them. Both are there to slow down the first blocks after liquidity lands, and both are removable in one direction only.
| Limit | At deploy | In tokens | Floor |
|---|---|---|---|
| Max transaction | 0.5% of supply | 5,000,000 | 0.1% of supply |
| Max wallet | 2.0% of supply | 20,000,000 | 0.5% of supply |
Limits apply only when neither side of a transfer is on the exemption list, and the max wallet check is skipped when the destination is an AMM pair, since a pair accumulating tokens is the normal case. The token contract, the router, the dead address, the treasury and the deployer are exempt from deployment.
What the owner keys can and cannot do
- Set buy and sell tax within the fixed cap
- Flag or unflag an address as an AMM pair
- Add or remove tax, limit and pre-trading exemptions
- Loosen or remove the transaction and wallet limits
- Tune the swap threshold and batch size within bounds
- Change the treasury address
- Enable trading once, and transfer or renounce ownership
- Mint new $FLAPA. No function exists
- Raise either tax cap. Both are compiled constants
- Set a limit below the floors in the code
- Move tokens out of a wallet it does not control
- Disable trading again once it has been enabled
- Change a finishing order or a settled payout
Every line above is a property of code that has not been deployed and has not been audited. Treat it as a statement of intent until you have read the verified source on BscScan yourself. The contract source is in contracts/FlapaToken.sol in this repository and can be compared line by line against whatever is eventually verified.
Pay tokens
You bet in the asset you already hold. Each asset has its own pool, its own odds and its own settlement, and they never mix.
Why more than one asset
BNB is what a BNB Smart Chain wallet already has, so betting in BNB costs a new player nothing but gas. Requiring $FLAPA instead would put a token purchase in front of a ninety second race, which is the wrong order of operations for someone who just wants to watch a round and back a bird.
Accepting only BNB has the opposite problem. It gives $FLAPA nothing to do inside the product it is named after, and it leaves holders paying a swap fee to take part in their own arena. Supporting both means neither group has to convert to play.
How the pools stay separate
A round does not have one pot. It has one pot per whitelisted pay token, and a bet joins the pot of the asset it was placed in. Settlement then runs the formula from section 04 once per token, independently, on that token's own numbers.
- Odds are per token. A bird can be the favourite in the BNB pool and a long shot in the $FLAPA pool at the same time. Both sets of odds are real and both are shown separately in the arena view.
- Payout is in the token you staked. Bet BNB, get paid BNB. Bet $FLAPA, get paid $FLAPA. There is no conversion at any point in settlement.
- The owner share is paid per token. If a bird wins a round with both pools funded, its owner is credited 5 percent of the BNB pot in BNB and 5 percent of the $FLAPA pot in $FLAPA, and withdraws each separately.
- Refund mode is per token. A round can pay out normally in one asset and refund in full in another, because whether anyone backed the winner is a question about one pool at a time.
- The burn share takes the shortest route. A $FLAPA burn share is transferred straight to the dead address with no trade. A BNB burn share is one hop, WBNB to $FLAPA. Any other listed asset routes through WBNB in two hops. The contract builds the path itself from the asset address.
The burn escrow
Settlement does not trade. It adds the burn share to a per-asset escrow and records when that escrow started filling. Trading inside a settlement transaction would put an unpredictable router call on the path everyone else's payouts depend on, and it would sell the same amount in many tiny pieces.
The escrow is emptied by one of two calls. The operator can call flushBurn with an explicit minimum output and deadline. Once the grace period has passed, 15 minutes from the start by default, flushBurnOpen becomes available to anyone: it quotes the swap from the router, allows 5 percent of slippage against that quote, and sends it. The operator does not have to be the one who flushes, and both the grace period and the slippage allowance are public values with the bounds listed in section 09.
If the swap reverts, the amount goes back into the escrow and a BurnFlushFailed event is emitted. A failed burn never eats the money and never blocks a settlement.
Merging them would mean converting one asset into the other at settlement, which needs a price. A price is an input, and an input on the settlement path is something an attacker can push around: move the pair a block before betting closes and every cross-token payout in that round moves with it. Keeping the pools separate deletes that input rather than defending it. The cost is thinner pools per asset, which is a real trade and the one this design accepts.
The registry
Which assets are accepted is an owner-controlled whitelist held on the arena itself, set with setPayToken(asset, enabled, minBet). Native BNB is listed as the zero address. Removing a token stops new bets in that asset immediately and does not touch anything already staked: rounds already open settle normally, and unclaimed payouts stay claimable forever.
Each entry carries a minimum bet, and a bet below it reverts. It exists so the gas of claiming stays smaller than the claim, and so a pool cannot be padded out with stakes too small to matter.
One detail worth knowing if you bet in a token that charges its own transfer fee. The arena credits you with what actually arrived, measured as the difference in its own balance across the transfer, not with the amount you asked to send. Your stake is always the real amount the contract holds for you.
Launch
The parameters the contracts start with, and the order the deployment steps have to run in.
Starting parameters
| Parameter | Value | Set by |
|---|---|---|
| Total supply | 1,000,000,000 FLAPA | constant, minted to the deployer |
| Decimals | 18 | constant |
| Buy tax | 250 bps | initial value, owner adjustable under the cap |
| Sell tax | 250 bps | initial value, owner adjustable under the cap |
| Per-side tax cap | 500 bps | constant, no setter |
| Combined tax cap | 500 bps | constant, no setter |
| Max transaction | 5,000,000 FLAPA | supply × 50 / 10,000 |
| Max wallet | 20,000,000 FLAPA | supply × 200 / 10,000 |
| Swap threshold | 50,000 FLAPA | supply / 20,000 |
| Swap batch cap | 500,000 FLAPA | supply / 2,000 |
| Trading at deploy | Disabled | enabled once, by enableTrading |
| Limits at deploy | Active | removable once, by removeLimits |
The invariant
The PancakeSwap V2 pair must exist and be flagged with setAmmPair(pair, true) before a single token of liquidity is added to it. Tax and limits are both keyed on that flag. If liquidity landed on an unflagged pair, every trade until the flag was set would clear at zero tax and outside the transaction and wallet limits, and there is no way to undo those trades afterwards. This is an invariant, not a recommendation, and it is enforced in code rather than left to whoever is running the deployment.
contracts/PancakeInit.sol is a small launch contract that holds the token owner role for the length of the launch and refuses to run the steps out of order. Its liquidity step reverts with PairNotReady if the pair has not been created and with ExemptionsMissing if the pair has not been flagged. There is no argument you can pass that skips either check.
Deployment order
- Deploy the token. The constructor takes the treasury address, mints the whole supply to the deployer, sets the limits from supply, and exempts the deployer, the token contract, the dead address, the router and the treasury. Trading is off.
- Deploy the launch contract and hand it the token owner role. setToken can only be called once, and every step then checks that the launch contract still owns the token.
- Create the pair. stepCreatePair() looks the pair up on the PancakeSwap V2 factory first and creates it only if it does not already exist, so a pair someone else made earlier is adopted rather than duplicated.
- Flag the pair and set exemptions. stepExemptions() calls setAmmPair(pair, true) and setLimitExempt(pair, true) on the token, then exempts the launch contract itself from limits, tax and the pre-trading gate so it can move tokens in the next step. Nothing is taxed and nothing is limited until this lands.
- Add liquidity. stepAddLiquidity pulls the token side from the caller, approves the router and calls addLiquidityETH with the BNB the contract holds. It works while trading is still disabled because the launch contract and the router are both past the pre-trading gate.
- Enable trading. stepEnableTrading() refuses to run before liquidity is in, and the token's own enableTrading can only ever succeed once. The block number is emitted so anyone can find the first tradable block.
- Hand the token owner role back. returnTokenOwnership passes ownership to whichever address will hold it from then on. The whole sequence is also available as one transaction through launch(), which runs the same steps in the same order with the same checks.
- Deploy the arena stack. Birds, art, draft and arena, then wire them with setContracts on both the arena and the draft reader, set the arena operator, and list BNB and $FLAPA with setPayToken and their minimum bets.
- Exempt the arena on the token. setTaxExempt so the buyback is not taxed on the way in, and setLimitExempt so the max wallet check cannot block it. The burn share is routed to the dead address, which is exempt from deployment.
- Publish every address. Verify each contract on BscScan and fill in section 10 of this document. Until that has happened, the app shows the not deployed state and refuses to transact.
Bounds on the arena settings
| Setting | Starts at | Allowed range | Why the bound is there |
|---|---|---|---|
| Betting window | 60 s | non-zero | a round with no betting time is not a round |
| Anchor offset | 134 blocks | 1 to 200 | keeps the anchor hash inside the 256 block horizon |
| Reveal timeout | 300 s | 120 s or more | a round cannot be voided out from under a slow close |
| Minimum bettors | 2 | 2 or more | one bettor cannot bet against themselves |
| Burn slippage | 5% | up to 20% | caps how bad an open flush is allowed to be |
| Burn flush grace | 900 s | unbounded | no floor and no ceiling, see the note below |
The last row is the one loose thread and it belongs in the open rather than buried. setBurnPolicy checks the slippage figure and does not check the grace period, so the owner can set the grace to any value. A very large value would not let them take the escrowed money, since no function moves it anywhere except to the dead address, but it would let them delay a public flush for as long as they wanted. The value in force is readable at any time as burnFlushGrace, and every change emits BurnPolicySet.
What anyone can check afterwards
- The PairReady event from the launch contract is at a lower block than the first Mint on the pair. That is pair before LP, read from chain history rather than taken on trust.
- The AmmPairSet event for the pair address also lands before that first Mint.
- MAX_SIDE_TAX_BPS and MAX_TOTAL_TAX_BPS both read 500 on the verified contract, and no function in the verified source writes to either.
- The sum of buyTaxBps and sellTaxBps never exceeds 500 across the full history of TaxUpdated events.
- No Transfer event exists with a zero address sender after the constructor. That is the absence of minting, shown rather than claimed.
- Every RoundOpened event carries its anchor block, and the matching RoundClosed event carries the anchor hash and the final seed. The two can be recombined by hand to check the seed.
Contracts
Every address below is blank because nothing is deployed. They fill in only from a real deployment, never by hand.
FLAPARENA contracts
| Contract | Source file | What it holds | Deployed |
|---|---|---|---|
| FlapaToken | FlapaToken.sol | The BEP-20, its tax and its limits | No |
| FlapBirds | FlapBirds.sol | The bird NFT, the roll and the traits | No |
| FlapBirdArt | FlapBirdArt.sol | The SVG renderer behind tokenURI | No |
| FlapDraft | FlapDraft.sol | The six buffs, their thresholds and their caps | No |
| FlapArena | FlapArena.sol | Rounds, bets, the on-chain race, settlement | No |
| PancakeInit | PancakeInit.sol | The launch sequence and its ordering checks | No |
Fixed addresses this system uses
These are not FLAPARENA contracts. They are the BNB Smart Chain and PancakeSwap V2 addresses the token contract hardcodes, and they are public and verifiable today.
Token functions worth knowing
| Function | Who can call | What it does |
|---|---|---|
| burn(uint256) | Anyone | Destroys the caller's tokens and lowers total supply |
| holdStreakBlocks(address) | Anyone, view | Blocks since that wallet's last outgoing transfer, the Ballast input |
| createPair() | Owner | Creates or adopts the FLAPA/WBNB V2 pair, once |
| setAmmPair(address,bool) | Owner | Flags an address as an AMM pair for tax and limits |
| setTax(uint16,uint16) | Owner | Sets buy and sell tax, reverts above either cap |
| tuneLimits(uint256,uint256) | Owner | Adjusts max transaction and max wallet, reverts below the floors |
| removeLimits() | Owner | Turns both limits off permanently |
| tuneSwap(bool,uint256,uint256) | Owner | Sets the tax swap threshold and batch size within bounds |
| enableTrading() | Owner | Opens transfers to non-exempt wallets, once |
| setTreasury(address) | Owner | Changes where swapped tax BNB is sent |
| renounceOwnership() | Owner | Sets the owner to the zero address, ending all owner calls |
Arena functions worth knowing
| Function | Who can call | What it does |
|---|---|---|
| openRound(bytes32,uint256[8]) | Operator | Opens a round, snapshots the field, fixes the anchor block |
| bet(uint256,uint8,address,uint256) | Anyone | Backs one bird in one pay token while the window is open |
| closeRound(uint256,bytes32) | Anyone with the secret | Reveals the secret and builds the final seed |
| voidRound(uint256) | Anyone | Voids a round whose secret never arrived, after the timeout |
| resolve(uint256) | Anyone | Runs the race on chain and stores the finishing order |
| settle(uint256) | Anyone | Splits every pay-token pool the round saw |
| claim(uint256,address,uint8) | Anyone with a stake | Takes a winning payout or a refund |
| withdraw(address) | Anyone owed | Takes a credited owner cut in one asset |
| flushBurnOpen(address) | Anyone, after the grace | Sends the burn escrow to the dead address at a quoted price |
| previewRace(bytes32,int256[6][8]) | Anyone, view | Runs any seed and field through the on-chain simulation |
| setPayToken(address,bool,uint96) | Owner | Lists or delists an asset and sets its minimum bet |
| setTiming(uint64,uint64,uint64,uint32) | Owner | Adjusts the four round timings within the bounds in section 09 |
previewRace is the one to reach for if you want to check the paper rather than take it. It is a view function, so it costs nothing to call, and it runs the same code path a real round runs.
Public counters
The token keeps four running totals so nobody has to reconstruct them from logs. Every one is a public variable readable with a single call.
Each renders as an em-dash because there is no contract to read. These fields populate from chain reads only. They are never estimated, cached from elsewhere or filled in by hand.
How to verify all of this yourself
- Open the token address on BscScan and confirm the source is verified and matches contracts/FlapaToken.sol in this repository.
- Read MAX_SIDE_TAX_BPS and MAX_TOTAL_TAX_BPS and search the verified source for any assignment to them. There is none.
- Check the event order described at the end of section 09 for the pair, the flag and the first liquidity mint.
- Pick any settled round, take its final seed and its eight stat blocks from the arena, run FlapSim.simulate(seed, field) from game/sim.js in a browser console, and compare the finishing order it returns against the one stored on chain.
- Feed the same seed and field to previewRace on the arena. It is a view call, and it should return the identical order. That is the JavaScript and the Solidity checked against each other on live data rather than in a test.
- Take a RoundClosed event, hash its secret and anchor hash together with the round id, and confirm you get the final seed the same event reports. Then hash the secret alone and confirm you get the seedCommit from the earlier RoundOpened event.
- Run FlapSim.selfCheck() in the same console. It should report a draw count of exactly 510 for every seed it tries.
- Call draftInputs and previewDraft on the draft reader for your own wallet, and check the six magnitudes against the raw facts and the thresholds in section 06.