Technical paper

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.

Version 1.0
Chain BNB Smart Chain, ID 56
Token $FLAPA, BEP-20
Simulation version FlapSim v1
Deployment None
01 / Overview

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

Birds per round8
Ticks per race360
Gates per race30, one every 12 ticks
Lane height1,000,000 units
Random words drawn per race510
Rake10% flat, split 5 / 5
Bird classes8
Abilities8
Draft buffs6
Pay tokens at launchBNB, $FLAPA
Betting window60 seconds, adjustable
Anchor block offset134 blocks, hard maximum 200

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.

02 / Lifecycle

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.

Round timeline. Every timing value is an arena storage variable with the starting value shown, adjustable by the owner within the bounds listed in section 09.
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.

03 / Fairness

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 honest summary

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.

04 / Economics

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

Of the pot 90% Winning bettors Split pro-rata to stake among the wallets that backed the winning bird.
Of the pot 5% Winning bird owner Paid to whoever owns the winning bird, even if they never placed a bet.
Of the pot 5% Buyback and burn Buys $FLAPA on PancakeSwap V2 and sends it to the burn address.
Pot composition
Winning bettors 90% Winning bird owner 5% Buyback and burn 5%

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

ILLUSTRATIVE. These are arithmetic examples with assumed inputs, not observed rounds. No round has been settled and no pot has existed.
Line Amount, BNB Share of pot Note
Total pot P100.000000100.0%assumed input
Stake on the winning bird S25.00000025.0%three wallets: 12, 8 and 5
Stake on the other seven birds75.00000075.0%loses in full
Winning pool90.00000090.0%P minus owner cut minus burn cut
Owner cut5.0000005.0%to the winning bird's owner
Burn cut5.0000005.0%buys $FLAPA, sends it to the burn address
Sum of the three shares100.000000100.0%closes exactly
ILLUSTRATIVE. The same assumed round, resolved to the three winning wallets. The return multiple is the winning pool divided by the winning stake, which is 3.6 in this example.
Wallet Stake Share of winning stake Payout Net
A12.00000048.0%43.200000+31.200000
B8.00000032.0%28.800000+20.800000
C5.00000020.0%18.000000+13.000000
Total25.000000100.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;
ILLUSTRATIVE, in wei, at a deliberately tiny scale so the truncation is visible. The order of claiming decides who collects the two spare wei, and nothing else changes.
Step Stake Paid Pool left Working
Start7090three wallets staked 3, 2 and 2
First claim3385290 × 3 / 7 = 38.57, truncated
Second claim2252790 × 2 / 7 = 25.71, truncated
Third claim2270last claimant, takes the remainder
Total paid7900the 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.
05 / Roster

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.

The clamps are hard bounds applied at the end of every derivation, by applyDraft on chain and by makeField again in the browser. A stat cannot leave its range no matter what ability and buff stack produced it.
Stat What it does Min Max Scale
FlapCeiling on the upward velocity a single flap can buy11,00028,000lane units per tick
StaminaSize of the tank the bird starts with160,000460,0001 point = 100 units
ReactionAim accuracy. Higher means a narrower error span01,000index
DodgeChance of surviving a gate clip as a graze0400per mille
SpeedGround covered per tick, the first ranking tiebreak7001,600distance units per tick
DrainStamina lost every tick before anything else happens120240per 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.

Base values from FlapSim.CLASSES. PWR is the advertised power rating for the base block with no ability and no buffs. The simulation never reads PWR; it exists so a number printed on a bird card can be reproduced by anyone.
Class Flap Stamina Reaction Dodge Speed Drain PWR
Skylark18,500317,000760901,180200185
Kestrel21,000264,5007001501,320202200
Ironwing16,200379,50070060960180178
Stormcrow19,500277,5008201101,060212184
Emberfinch24,000242,0006601301,240208196
Nightjar16,500329,5008802001,000195212
Galehawk22,500259,0007201001,400216191
Cinderteal17,500328,0007001501,100203194

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.

From FlapSim.ABILITIES. Percentages apply to the class base value. Flat additions are added to it directly.
Ability Effect on the class base Reads as
Thermal LockFlap +6.0%a bigger correction per flap
Second WindStamina +10.0%the largest single tank buff
Ghost FeatherDodge +60 per mille6 more grazes survived in every hundred clips
Hair TriggerReaction +80a tighter aim error span
Draft RiderSpeed +8.0%wins ranking ties on distance
Hollow BoneDrain −12.0%the tank lasts longer without being bigger
Gale HeartFlap +3.0%, Speed +3.0%, Reaction +30three small pushes instead of one large
Iron LungStamina +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.

06 / Buffs

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

Caps are compiled constants in contracts/FlapDraft.sol and match FlapSim.deriveStats exactly. The full-value column is the starting threshold, owner-adjustable and always non-zero.
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.

Computed by running FlapSim.deriveStats(0, 0, draft) with every magnitude at 0 and then at 1000. Reproducible in a browser console with game/sim.js loaded.
Stat No buffs Every buff maxed Change
Flap19,61020,394+4.0%
Stamina317,000377,230+19.0%
Reaction760817+57
Dodge90116+28.9%
Speed1,1801,368+15.9%
Drain200160−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.

07 / Token

Tokenomics

One fixed supply minted at deployment, a transfer tax with a cap that cannot be raised, and a burn funded by betting.

Supply

Name and symbolFlaparena, FLAPA
StandardBEP-20, 18 decimals
Total supply1,000,000,000 FLAPA
MintedOnce, in the constructor
Mint function after deployNone exists
Supply canOnly fall, and only through burn()

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.

Basis points, where 10,000 bps is 100 percent. Values read from contracts/FlapaToken.sol. The two MAX_ values are Solidity constants, compiled into the bytecode, with no setter anywhere in the contract.
Parameter Value at deploy Hard cap Changeable by
Buy tax250 bps, 2.5%500 bps, 5%setTax, owner only
Sell tax250 bps, 2.5%500 bps, 5%setTax, owner only
Buy plus sell combined500 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.

Swap threshold50,000 FLAPA, supply / 20,000
Maximum per swap500,000 FLAPA, supply / 2,000
Ceiling on the batch size1% of supply, enforced in tuneSwap
RouteFLAPA to WBNB, PancakeSwap V2
DestinationTreasury address

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.

The token tax is a trading fee. The burn share is a betting fee. A round with no bets produces no burn regardless of how much $FLAPA trades that day.
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.

tuneLimits reverts with BadLimits below the floors, so the owner can loosen the limits or lift them entirely with removeLimits, but cannot squeeze them tighter than the floors below.
Limit At deploy In tokens Floor
Max transaction0.5% of supply5,000,0000.1% of supply
Max wallet2.0% of supply20,000,0000.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

Can
  • 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
Cannot
  • 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.

08 / Settlement

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.

Why not merge the pools

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.

09 / Deployment

Launch

The parameters the contracts start with, and the order the deployment steps have to run in.

Starting parameters

Every value here is set in the constructor of contracts/FlapaToken.sol or is a compiled constant. None of them is a target, a projection or a plan. They are what the code does.
Parameter Value Set by
Total supply1,000,000,000 FLAPAconstant, minted to the deployer
Decimals18constant
Buy tax250 bpsinitial value, owner adjustable under the cap
Sell tax250 bpsinitial value, owner adjustable under the cap
Per-side tax cap500 bpsconstant, no setter
Combined tax cap500 bpsconstant, no setter
Max transaction5,000,000 FLAPAsupply × 50 / 10,000
Max wallet20,000,000 FLAPAsupply × 200 / 10,000
Swap threshold50,000 FLAPAsupply / 20,000
Swap batch cap500,000 FLAPAsupply / 2,000
Trading at deployDisabledenabled once, by enableTrading
Limits at deployActiveremovable once, by removeLimits

The invariant

Pair before LP

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

setTiming and setBurnPolicy reject anything outside these bounds. The bounds themselves are in the compiled code with no setter.
Setting Starts at Allowed range Why the bound is there
Betting window60 snon-zeroa round with no betting time is not a round
Anchor offset134 blocks1 to 200keeps the anchor hash inside the 256 block horizon
Reveal timeout300 s120 s or morea round cannot be voided out from under a slow close
Minimum bettors22 or moreone bettor cannot bet against themselves
Burn slippage5%up to 20%caps how bad an open flush is allowed to be
Burn flush grace900 sunboundedno 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.
10 / Reference

Contracts

Every address below is blank because nothing is deployed. They fill in only from a real deployment, never by hand.

FLAPARENA contracts

$FLAPA token not deployed
Arena not deployed
Bird NFT not deployed
Draft reader not deployed
Bird art not deployed
PancakeSwap V2 pair not created
Treasury not set
Source status in this repository. Every contract is written and none has been audited, reviewed or deployed. The pay-token whitelist is not its own contract: it lives on the arena as setPayToken.
Contract Source file What it holds Deployed
FlapaTokenFlapaToken.solThe BEP-20, its tax and its limitsNo
FlapBirdsFlapBirds.solThe bird NFT, the roll and the traitsNo
FlapBirdArtFlapBirdArt.solThe SVG renderer behind tokenURINo
FlapDraftFlapDraft.solThe six buffs, their thresholds and their capsNo
FlapArenaFlapArena.solRounds, bets, the on-chain race, settlementNo
PancakeInitPancakeInit.solThe launch sequence and its ordering checksNo

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.

ChainBNB Smart Chain, ID 56 (0x38)
PancakeSwap V2 router0x10ED43C718714eb63d5aA57B78B54704E256024E
PancakeSwap V2 factory0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73
WBNB0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c
Burn address0x000000000000000000000000000000000000dEaD

Token functions worth knowing

Selected from contracts/FlapaToken.sol. Standard BEP-20 functions are omitted. Everything marked owner only reverts with NotOwner for any other caller.
Function Who can call What it does
burn(uint256)AnyoneDestroys the caller's tokens and lowers total supply
holdStreakBlocks(address)Anyone, viewBlocks since that wallet's last outgoing transfer, the Ballast input
createPair()OwnerCreates or adopts the FLAPA/WBNB V2 pair, once
setAmmPair(address,bool)OwnerFlags an address as an AMM pair for tax and limits
setTax(uint16,uint16)OwnerSets buy and sell tax, reverts above either cap
tuneLimits(uint256,uint256)OwnerAdjusts max transaction and max wallet, reverts below the floors
removeLimits()OwnerTurns both limits off permanently
tuneSwap(bool,uint256,uint256)OwnerSets the tax swap threshold and batch size within bounds
enableTrading()OwnerOpens transfers to non-exempt wallets, once
setTreasury(address)OwnerChanges where swapped tax BNB is sent
renounceOwnership()OwnerSets the owner to the zero address, ending all owner calls

Arena functions worth knowing

Selected from contracts/FlapArena.sol. Note how much of the round machinery is open to any caller: the only step reserved for the operator is opening a round.
Function Who can call What it does
openRound(bytes32,uint256[8])OperatorOpens a round, snapshots the field, fixes the anchor block
bet(uint256,uint8,address,uint256)AnyoneBacks one bird in one pay token while the window is open
closeRound(uint256,bytes32)Anyone with the secretReveals the secret and builds the final seed
voidRound(uint256)AnyoneVoids a round whose secret never arrived, after the timeout
resolve(uint256)AnyoneRuns the race on chain and stores the finishing order
settle(uint256)AnyoneSplits every pay-token pool the round saw
claim(uint256,address,uint8)Anyone with a stakeTakes a winning payout or a refund
withdraw(address)Anyone owedTakes a credited owner cut in one asset
flushBurnOpen(address)Anyone, after the graceSends the burn escrow to the dead address at a quoted price
previewRace(bytes32,int256[6][8])Anyone, viewRuns any seed and field through the on-chain simulation
setPayToken(address,bool,uint96)OwnerLists or delists an asset and sets its minimum bet
setTiming(uint64,uint64,uint64,uint32)OwnerAdjusts 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.

totalTaxed
totalSwapped
totalBnbRouted
totalBurned

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.